
How to Import CSV Records Into Salesforce
An External ID is a custom field Salesforce indexes so a record can be found by a key another system already owns. Four custom field types carry the attribute, AutoNumber, Email, Number and Text. Point an upsert at that field and Salesforce decides for itself whether to create a record or update one. A broker platform importing an agency's book of business needs exactly that. The agency knows its policies by carrier and policy number, and it knows nothing about a Salesforce id.
The agency's export arrives looking like this.
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| 1 | Cxr Cd | Policy No | Insured | Eff Date | Exp Date | Premium | Line | Status |
| 2 | AXA-UK | PL-4417 | Redwood Joinery Ltd | 01/02/2026 | 31/01/2027 | 1.250,00 | General Liability | Active |
| 3 | HISCOX | PL-4417 | Marden Freight Ltd | 15/03/2026 | 14/03/2027 | 1950,00 | GL | Current |
| 4 | axa-uk | PL-5108 | Ollerton Care Homes | 01-Feb-26 | 31-Jan-27 | 4380 | WORKERS COMP | Active |
| 636 rows not shown | ||||||||
| 641 | HISCOX | PL-6042 | Thornbury Plant Hire Ltd | 01/06/2026 | 31/05/2027 | 2760 | GL | Active |
1Cxr Cd,Policy No,Insured,Eff Date,Exp Date,Premium,Line,Status2AXA-UK,PL-4417,Redwood Joinery Ltd,01/02/2026,31/01/2027,"1.250,00",General Liability,Active3HISCOX,PL-4417,Marden Freight Ltd,15/03/2026,14/03/2027,"1950,00",GL,Current4axa-uk,PL-5108 ,Ollerton Care Homes,01-Feb-26,31-Jan-27,4380,WORKERS COMP,Active⋮636 rows not shown641HISCOX,PL-6042,Thornbury Plant Hire Ltd,01/06/2026,31/05/2027,2760,GL,ActiveEight headers, and none of them carry the names the object uses. Two rows hold the same policy number under different carriers. The third carrier code arrives lowercase and its policy number trails a space. Two premiums use a comma for the decimal point. Four dates use slashes, and the Ollerton Care Homes row writes its two as 01-Feb-26 and 31-Jan-27. The line column says General Liability, GL and WORKERS COMP.
The person hands that book of business to the importer inside your app. Updog Importer opens it in the browser, maps those eight headers onto your columns, and shows the person every value. Your onComplete handler receives the rows. The handler posts them two hundred at a time to a route you own. The route holds the Salesforce credentials and calls the sObject Collections upsert. Salesforce writes the custom object.
Updog runs no server anywhere between that browser and Salesforce.
Step 1. Give the object an External ID field
Start with the field the whole import turns on.
Policy__c custom object Broker_Key__c Text(80) External ID, Unique Carrier_Code__c Text(16) Policy_Number__c Text(32) Insured_Name__c Text(120) Effective_Date__c Date Expiry_Date__c Date Premium__c Number(16, 2) Line_Of_Business__c Picklist Status__c PicklistBroker_Key__c carries the External ID attribute and holds one string built from the agency, the carrier and the policy number. An upsert URL names one field, so a natural key of several parts arrives in it concatenated.
External ID with the Unique attribute gives a unique index and needs no special permission. External ID on its own gives a non-unique index, and the calling application needs View All Data. Marking the field Unique is what keeps one key on one record.
Matching is case-sensitive by default. Salesforce puts it plainly, that matching by external ID is case-insensitive only when the field has the Unique attribute with the option treating ABC and abc as duplicate values. So axa-uk and AXA-UK reach two records unless something upstream settles the case.
Step 2. Describe the object as columns
The columns array is the object written for the person looking at the file. Each entry sets a title they read, an editor deciding how a cell is typed, and validators marking what fails.
import type { DataEditorColumn } from "@updog/data-editor";
const LINES = ["General Liability", "Commercial Auto", "Workers Compensation"];const STATUSES = ["Active", "Lapsed", "Cancelled"];
export const columns: DataEditorColumn[] = [ { id: "carrierCode", title: "Carrier code", size: 130, transformer: (value) => String(value).trim().toUpperCase(), validators: [{ type: "required" }], }, { id: "policyNo", title: "Policy number", size: 140, transformer: (value) => String(value).trim(), validators: [{ type: "required" }], }, { id: "insuredName", title: "Insured name", size: 220, validators: [{ type: "required" }], }, { id: "effectiveDate", title: "Effective date", size: 140, editor: { type: "date" }, validators: [ { type: "required" }, { type: "date" }, ], }, { id: "expiryDate", title: "Expiry date", size: 140, editor: { type: "date" }, validators: [{ type: "date" }], }, { id: "premium", title: "Premium", size: 120, editor: { type: "number" }, validators: [ { type: "number", min: 0, decimalPlaces: 2 }, ], }, { id: "lineOfBusiness", title: "Line of business", size: 190, editor: { type: "select", options: LINES, enableCustomValue: false }, validators: [{ type: "oneOf", values: LINES }], }, { id: "status", title: "Status", size: 120, editor: { type: "select", options: STATUSES, enableCustomValue: false }, validators: [{ type: "oneOf", values: STATUSES }], },];Each editor answers a value the agency's export writes in its own way. The number editor sees 1.250,00, reads the comma as the decimal point because it sits after the dot, and lands 1250.00. 1950,00 follows the same reading and lands 1950.00. The date editor turns 01/02/2026 into 2026-02-01, which is the yyyy-MM-dd a Salesforce date field takes as it stands. The file settles that reading on its own, because 31/01/2027 carries a day past 12 and no month-first reading survives it.
01-Feb-26 matches none of the patterns the parser holds, so it reaches the grid as typed and the date validator flags the cell. The person fixes two cells and moves on.
The transformer on carrierCode uppercases and trims. That is what keeps axa-uk and AXA-UK on one External ID value at the far end of the chain.
The synonyms prop teaches matching the words the agency already uses, and the mount ties the whole thing together.
<DataEditor<Policy> apiKey="your-license-key" variant="uploader" open={open} onClose={closeImporter} columns={columns} primaryKey={["carrierCode", "policyNo"]} synonyms={{ columns: { carrierCode: ["cxr cd", "carrier", "carrier cd"] }, values: { "General Liability": ["gl", "gen liab"] }, }} onComplete={onComplete}/>Column synonyms map Cxr Cd onto carrierCode, a pair no score reaches on its own, since the two strings share no word and neither one contains the other. Policy No lands exactly, Insured and Line land because the schema name contains them, and Eff Date lands on the word it shares with Effective date. Value synonyms map GL onto General Liability. WORKERS COMP needs no help, because Workers Compensation contains it once matching lowercases the value and drops the spaces. Current reaches Active through the built-in table, which already covers status vocabularies.
Every match the person corrects by hand returns as learnedSynonyms, ready to store and replay on the agency's next export. How to remember CSV import mappings between uploads follows those matches from one export to the next. The package install and the modal state behind open and onClose sit in how to import a CSV file into a React app.
Every snippet here is React. The web component build takes the same props, so a Vue, Angular or Svelte app writes this columns array and this handler unchanged.
Step 3. Point the primary key at both halves of the identity
primaryKey decides how an imported row meets a row already in the grid. It accepts one column or a list of them, and identity here takes two. An imported row merges into an existing one only when every listed column matches. Rows inside one file are appended beside each other, so both PL-4417 policies reach the grid whatever the key says.
The key earns its keep on the second file and on every repeat import. Set it to policyNo alone and the agency's next export lands one carrier's PL-4417 on the other carrier's row. Pass ["carrierCode", "policyNo"] and each policy keeps its own row through every pass.
A row with an empty value in any part of the key merges with nothing and arrives as new. Values are compared after surrounding whitespace is trimmed, so the space trailing PL-5108 costs nothing.
Leave the unique validator off both columns. Uniqueness in Updog is relational per column, checked against every other row in the same column, and a carrier code repeats across a whole book by design.
The primary key step in the wizard appears when there is something to merge into and a key to offer. A multi-column primaryKey counts as a key once every part of it is mapped, and it shows up as one choice named after its columns.
The same two columns decide the External ID at the far end. A key built from the policy number alone gives two policies one Broker_Key__c, and both of them travel inside one chunk.
Step 4. Post the result in chunks of 200
Pressing submit hands your handler every policy grouped by source, each row carrying isNew, isChanged, isDeleted and isValid. Rows nobody touched stay out. A book of business can arrive as three files in one import, each file its own source entry. The handler flattens them before it slices.
import type { DataEditorResult } from "@updog/data-editor";
const CHUNK_SIZE = 200;
const onComplete = useCallback(async (result: DataEditorResult<Policy>) => { 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 += CHUNK_SIZE) { const response = await fetch("/api/policies/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rows: rows.slice(start, start + CHUNK_SIZE) }), });
if (!response.ok) { const failure = await response.json(); throw new Error(failure.message); } }}, []);Salesforce publishes the two hundred. The sObject Collections resource takes a list that can contain up to 200 objects, all of them the same object type, and the whole request counts as one call toward the org's API allocation. That allocation is published too. A Developer Edition org holds 15,000 calls per 24 hours, and an Enterprise org holds 100,000 plus 1,000 for each Salesforce license plus whatever add-ons it bought. Forty thousand policies at 200 a request is 200 calls.
Throw when a chunk of policies fails. Updog Importer waits on that promise and clears the editor the moment it resolves. Catch that error inside the handler and return, and Updog reads a success. The grid empties with those rows unwritten. Throwing holds the policies, the mappings and the fixes in place, so the person submits again on a book they can still see.
A spinner in the confirm dialog covers the whole loop, which is the other reason chunks hold 200. Copy anything you want off the result inside the handler, because the editor drops its rows, its sources, its history and its learned synonyms once the promise resolves.
That isValid filter decides which policies never reach Salesforce. A failed validator marks the cell and lets the person submit anyway, so flagged rows reach your handler and the filter drops them on the floor. Pass blockSubmitOnError to the editor and submit stays disabled until every policy validates.
Step 5. Upsert the chunk from your own route
The browser reaches this route and stops there, and the Salesforce token sits behind it.
import express from "express";
const app = express();app.use(express.json({ limit: "2mb" }));
const API_VERSION = "v67.0";
app.post("/api/policies/import", async (request, response) => { const session = await getVerifiedSession(request); if (!session) { return response.status(401).json({ message: "Not signed in" }); }
const { instanceUrl, accessToken } = await getSalesforceToken(); const records = request.body.rows.map((row) => ({ attributes: { type: "Policy__c" }, Broker_Key__c: session.agencyCode + "_" + row.carrierCode + "_" + row.policyNo, Carrier_Code__c: row.carrierCode, Policy_Number__c: row.policyNo, Insured_Name__c: row.insuredName, Effective_Date__c: row.effectiveDate, Expiry_Date__c: row.expiryDate, Premium__c: row.premium === "" ? null : Number(row.premium), Line_Of_Business__c: row.lineOfBusiness, Status__c: row.status, }));
const salesforce = await fetch( instanceUrl + "/services/data/" + API_VERSION + "/composite/sobjects/Policy__c/Broker_Key__c", { method: "PATCH", headers: { Authorization: "Bearer " + accessToken, "Content-Type": "application/json", }, body: JSON.stringify({ allOrNone: false, records }), }, );
const results = await salesforce.json();
if (!salesforce.ok) { return response.status(502).json({ message: results[0].message }); }
const failures = results.filter((entry) => !entry.success);
if (failures.length > 0) { return response.status(400).json({ message: failures[0].errors[0].message, statusCode: failures[0].errors[0].statusCode, failed: failures.length, }); }
return response.json({ written: results.length });});getVerifiedSession() stands in for your own server-side authentication check, and getSalesforceToken() for the token exchange. Salesforce documents an OAuth 2.0 client credentials flow for server-to-server integration. It runs as an integration user with no person in the loop.
That token carries the integration user's access to every agency in the org, so the route decides tenancy itself. session.agencyCode comes off the verified session and never off the request body. It goes into the External ID value, so an agency's rows land on keys carrying that agency's code alone.
The rest of the mapping is small. premium arrives as the string 1250.00, and converting it before it enters the record keeps the payload matching a number field. effectiveDate arrives as 2026-02-01 and needs nothing. A dateTime field would want the offset form as well.
allOrNone decides what one bad policy costs. It defaults to false, and the documented behaviour is to continue with the independent creation of the other objects in the request. Set it to true and Salesforce rolls the whole request back, marking the records that would have landed with ALL_OR_NONE_OPERATION_ROLLED_BACK. A book of business is a pile of independent policies, so false keeps the other 199.
The jsforce library wraps the same call and carries one caveat worth reading first. Its documentation says an upsert with the allOrNone option will not revert successful upserts when one or more fail.
Step 6. Read the per-record result
Salesforce answers a collections upsert with 200 OK and an array holding one entry per record. The status line says nothing about the records. A request Salesforce turns down outright answers with its own status and an array of error objects, so the route reads salesforce.ok before it counts per-record failures.
[ { "id": "a03xx000003DHP0AAO", "success": true, "errors": [], "created": true }, { "success": false, "errors": [ { "statusCode": "DUPLICATE_VALUE", "message": "duplicate value found: Broker_Key__c duplicates value on record with id: a03xx000003DHP1AAO", "fields": ["Broker_Key__c"] } ] }]created separates an insert from an update, and it is the closest thing to a report of what the External ID matched. Salesforce documents three outcomes. An External ID matching nothing creates a record. An External ID matching one record updates it. An External ID matching several records creates and updates nothing. The single-record upsert URL reports that third outcome as a 300 over the whole response. A collections call keeps its 200 OK and carries the outcome on the one record that hit it.
DUPLICATE_EXTERNAL_ID covers a user-specified external ID matching more than one record during an upsert. DUPLICATE_VALUE covers a duplicate value supplied for a field that must be unique. Two rows carrying the same External ID inside one call get their own rule, and Salesforce says those records are marked as errors in the upsert result. DUPLICATES_DETECTED belongs to duplicate rules, so only an org running them ever sees it.
Chunks that already landed stay landed. An upsert on the same External ID writes the same values a second time, so a retry after a failed chunk costs a rewrite of what already exists. That is what makes pressing submit again safe.
Step 7. Move to Bulk API 2.0 when the file grows
A loop of 200-row requests stops paying at some size, and Salesforce draws the line at 2,000 records. Any data operation above that is a good candidate for Bulk API 2.0, and jobs below it belong in bulkified synchronous calls in REST or SOAP. Bulk API 2.0 takes a whole CSV as one job and works through it in the background.
const base = instanceUrl + "/services/data/v67.0/jobs/ingest";const auth = { Authorization: "Bearer " + accessToken };
const job = await fetch(base, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ object: "Policy__c", operation: "upsert", externalIdFieldName: "Broker_Key__c", contentType: "CSV", }),}).then((res) => res.json());
await fetch(base + "/" + job.id + "/batches", { method: "PUT", headers: { ...auth, "Content-Type": "text/csv" }, body: csv,});
await fetch(base + "/" + job.id, { method: "PATCH", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ state: "UploadComplete" }),});
const failed = await fetch(base + "/" + job.id + "/failedResults/", { headers: auth,}).then((res) => res.text());Four calls replace the loop. Create the job with externalIdFieldName naming the same External ID field. Upload the CSV, and one request carries up to 150 MB of base64 encoded content, so Salesforce advises keeping the file itself under 100 MB. Close the job and Salesforce starts work. Read failedResults afterwards for a CSV carrying sf__Error and sf__Id beside the original fields.
The ceilings sit far out. Bulk API 2.0 uploads 150,000,000 records per rolling 24 hours, a field holds 131,072 characters, a record holds 400,000, and a finished job keeps its status and results for seven days before Salesforce deletes them. The CSV goes up in UTF-8, its headers are the field API names, and #N/A is how a value is set to null.
The person is still waiting behind the confirm dialog while your handler runs. A job Salesforce works through in the background outlives that wait, so the route accepts the rows, starts the job, and answers straight away. The editor clears on that answer, and whatever the job reports later reaches the person through a screen you own.
The seats Salesforce's own tools need
Updog Importer integrates with nobody. There is no Salesforce connector, no destination list, no webhook and no server of ours. onComplete hands your code a result object, and the route between your app and Salesforce is yours to write.
Salesforce already ships its own import for the other case. The Data Import Wizard sits in the Setup menu and takes up to 50,000 records at a time, across common standard objects and custom ones. Data Loader is a client application installed on a desktop, and it carries up to 150 million records when it runs on Bulk API 2.0. Both start from the seat of somebody who holds the org. A file already sitting on the org holder's desk travels faster through those two. The seven steps above exist for a book of business belonging to an agency, arriving in a browser session your app issued. Client-side and server-side CSV import weighs the two models against each other.
The identity the agency already uses
You wrote an External ID field, a schema of eight columns, a key made of two of them, a handler that chunks at 200, and a route that holds one token. The agency's export never leaves the machine that opened it. The rows travel from your own front end to your own route, and from there into Salesforce, and the only party you added to the chain is yourself. Point the same setup at a React CSV importer modal or a plain web component and the middle stays the same.
If the agency knows a policy by its carrier and its number, Salesforce can find that policy by the same two things.