Back to all postsA paper cut-out shopfront with an orange and cream striped awning, a blue front and a grey door and window, on a tan paper card

CSV Import for Procurement and Vendor Management Software

A supplier list grows one buyer at a time. Somebody adds a vendor for a single purchase order, somebody else adds payment terms, and finance adds a tax ID a year later. By the time that list leaves the old system it holds every field anyone there ever needed.

Payment terms and tax IDs are the fields your platform expects. The rest is whatever that one company decided to track, and it reaches you as a column header you have never seen.

Six of the eighteen headers in the file below name fields this platform never shipped. Three of those, Risk Tier, Insurance Expiry and Plants Served, exist nowhere outside this one customer.

A procurement platform lets its customers decide what a supplier is. Coupa writes the consequence into its own integration documentation. "For CSV Import, Prompt field value is used as column header." A Prompt is a string a buyer typed into an admin screen, and it becomes a column header in the export. Your code cannot carry that string, because your code shipped before the customer existed.

So the field list arrives over the wire and the schema is built at mount. Customer data onboarding is the work of turning a customer's supplier list into your own records. On a procurement platform the target of that work moves with the tenant.

The file below runs 3,120 rows over 18 columns, 545 KB of .csv. Every figure in this article was read off imports of that one file.

The supplier list an ERP hands over

A customer moving onto your platform exports the suppliers they already hold. The column names come out of the system they are leaving, so a Coupa supplier information export carries Supplier Number, Preferred Currency, Invoice Matching Level, Minority Indicator and Minority Type. A NetSuite vendor list carries different ones. Seven rows out of this file, in the spelling the export used.

supplier-export.csv
ABCDEFGHIJKLMNOPQR
1Supplier NumberCompany NameDisplay NameSIM StatusOrganization TypeFederal Tax IDDUNSCountry of OperationPreferred CurrencyPayment TermInvoice Matching LevelMinority IndicatorMinority TypePrimary Contact EmailPay GroupRisk TierInsurance ExpiryPlants Served
2V-100424Quarrington Springs PLCQuarrington SpringsPendingCorporation35586970497825272GBusd2/10 Net 303-wayTRUESDVOSB[email protected]; [email protected]203/26/2026PLT-08;PLT-04;PLT-02
3V-100862Lansdowne Forgings LtdDraftLLC66-4988758902636648USAUSDNet 303-wayFalse[email protected]; [email protected]High2026-11-25PLT-05 / PLT-12
4V-100031Ashworth Foundry CorpAshworth FoundryActiveS-Corp38-3386951703093457DEUSDNet 303-way1WBE[email protected]CA-AP12026-12-10PLT-11
5Draycott Seals Inc.Draycott SealsALimited Liability Company99-6577700908805639DEUSDNet 903-way-directYESDBE[email protected]EU-APLowPLT-12;PLT-05
6V-100022Saltburn Gasket GmbHA698433731258080784USUSDNet 303-wayFalsesaltburn.gasket.saltburngas.comMX-AP309/27/2028PLT-02
7V-100023Ingleby Forgings IncActiveLimited Liability Company24279963815-184-8574CANet 452 wayFingleby.forgings.inglebyfor.comUS-APLow2026-06-05PLT-05, PLT-02
3113 rows not shown
3121V-103118Wendover Bearings LtdWendover BearingsActiveCorporation84-2210567551908364GBUSDNet 303-wayFalse[email protected]EU-APLow2027-02-19PLT-08
1Supplier Number,Company Name,Display Name,SIM Status,Organization Type,Federal Tax ID,DUNS,Country of Operation,Preferred Currency,Payment Term,Invoice Matching Level,Minority Indicator,Minority Type,Primary Contact Email,Pay Group,Risk Tier,Insurance Expiry,Plants Served2V-100424,Quarrington Springs PLC,Quarrington Springs,Pending,Corporation,355869704,97825272,GB,usd,2/10 Net 30,3-way,TRUE,SDVOSB,[email protected]; [email protected],,2,03/26/2026,PLT-08;PLT-04;PLT-023V-100862,Lansdowne Forgings Ltd,,Draft,LLC,66-4988758,902636648,USA,USD,Net 30,3-way,False,,[email protected]; [email protected],,High,2026-11-25,PLT-05 / PLT-124V-100031,Ashworth Foundry Corp,Ashworth Foundry,Active,S-Corp,38-3386951,703093457,DE,USD,Net 30,3-way,1,WBE,[email protected],CA-AP,1,2026-12-10,PLT-115,Draycott Seals Inc.,Draycott Seals,A,Limited Liability Company,99-6577700,908805639,DE,USD,Net 90,3-way-direct,YES,DBE,[email protected],EU-AP,Low,,PLT-12;PLT-056V-100022,Saltburn Gasket GmbH,,A,,698433731,258080784,US,USD,Net 30,3-way,False,,saltburn.gasket.saltburngas.com,MX-AP,3,09/27/2028,PLT-027V-100023,Ingleby Forgings Inc,,Active,Limited Liability Company,242799638,15-184-8574,CA,,Net 45,2 way,F,,ingleby.forgings.inglebyfor.com,US-AP,Low,2026-06-05,"PLT-05, PLT-02"3113 rows not shown3121V-103118,Wendover Bearings Ltd,Wendover Bearings,Active,Corporation,84-2210567,551908364,GB,USD,Net 30,3-way,False,,[email protected],EU-AP,Low,2027-02-19,PLT-08

V-100424 has a DUNS of eight digits. V-100862 writes two plant codes with a slash between them. The row for Draycott Seals arrives with no supplier number at all. And two addresses share the Primary Contact Email cell, which is how accounts payable keeps a mailbox and a person in one field.

The file names 3,120 suppliers and 2,857 tax numbers. Updog reads it, matches it and checks it in the tab the person opened, so your endpoint is the first machine to receive a row. Where that work runs is a design decision, and client-side and server-side import walks both.

The fields your customer added themselves

Twelve fields are yours and ship with the product. Supplier number, legal name, display name, status, organization type, tax id, D-U-N-S, country, currency, payment terms, invoice matching level and contact email. Every customer gets those.

The rest comes back from your own configuration endpoint.

export type SupplierField =
| { kind: "text"; id: string; label: string; headerAliases?: string[] }
| { kind: "date"; id: string; label: string; headerAliases?: string[] }
| {
kind: "choice";
id: string;
label: string;
options: string[];
headerAliases?: string[];
valueAliases?: Record<string, string[]>;
}
| {
kind: "multi";
id: string;
label: string;
options: string[];
separator?: string;
headerAliases?: string[];
}
| {
kind: "boolean";
id: string;
label: string;
headerAliases?: string[];
valueAliases?: Record<string, string[]>;
};
// What GET /tenants/:id/supplier-fields answers for one customer.
export const fields: SupplierField[] = [
{
kind: "boolean",
id: "diverseOwned",
label: "Diverse Owned",
headerAliases: ["minority indicator", "diverse supplier"],
valueAliases: { Yes: ["t", "1"], No: ["f", "0"] },
},
{
kind: "choice",
id: "diversityCategory",
label: "Diversity Category",
options: ["MBE", "WBE", "WOSB", "VOSB", "SDVOSB", "HUBZone", "DBE"],
headerAliases: ["minority type", "certification"],
},
{
kind: "choice",
id: "payGroup",
label: "Pay Group",
options: ["US-AP", "EU-AP", "CA-AP", "MX-AP", "UK-AP"],
},
{
kind: "choice",
id: "riskTier",
label: "Risk Tier",
options: ["1", "2", "3"],
valueAliases: { "1": ["low"], "2": ["medium"], "3": ["high"] },
},
{ kind: "date", id: "insuranceExpiry", label: "Insurance Expiry" },
{
kind: "multi",
id: "plantsServed",
label: "Plants Served",
options: ["PLT-01", "PLT-02", "PLT-03", "PLT-04", "PLT-05", "PLT-06",
"PLT-07", "PLT-08", "PLT-09", "PLT-10", "PLT-11", "PLT-12"],
separator: " / ",
},
];

That array is data your admin screen already writes. It holds the label the customer chose, the options they set, and two things worth adding while you are there. headerAliases records what the field is called in the systems this customer exports from. separator records how their files write a list in one cell. Both answers exist inside the customer and nowhere in your repository.

A schema assembled at mount

columns takes an array of plain objects, so a schema is data and data can be built. One function turns a configured field into a column.

import type { DataEditorColumn } from "@updog/data-editor";
import { core } from "./core";
import type { SupplierField } from "./fields";
const toColumn = (field: SupplierField): DataEditorColumn => {
switch (field.kind) {
case "date":
return { id: field.id, title: field.label, editor: { type: "date" } };
case "boolean":
return {
id: field.id,
title: field.label,
editor: { type: "select", options: ["Yes", "No"], enableCustomValue: false },
};
case "choice":
return {
id: field.id,
title: field.label,
editor: { type: "select", options: field.options, enableCustomValue: false },
validators: [{ type: "oneOf", values: field.options }],
};
case "multi":
return {
id: field.id,
title: field.label,
editor: {
type: "multiselect",
options: field.options,
delimiter: field.separator,
enableCustomValue: false,
},
};
default:
return { id: field.id, title: field.label };
}
};
export const buildColumns = (fields: SupplierField[]): DataEditorColumn[] => {
return [...core, ...fields.map(toColumn)];
};

A choice field becomes a select with enableCustomValue: false, which closes the list so nobody invents an eighth diversity certification during an import. A multi field becomes a multiselect over the same options. A boolean becomes a two-option select, because seventeen written forms of yes and no have to land on one of two strings, and the value-matching step is where they land.

Nothing in that function is procurement. It is a switch over your own field kinds, and it runs before the importer opens.

Diverse Owned against Minority Indicator

Sixteen of the eighteen headers reach a column before an alias is applied. The two that miss are Minority Indicator and Minority Type, against columns titled Diverse Owned and Diversity Category.

Neither name is wrong. The file speaks the vocabulary of the system that wrote it, and the schema speaks the vocabulary the buyer typed into your admin screen. No matcher bridges those on its own, because the two strings share no word.

The bridge is the same array that produced the columns.

export const buildSynonyms = (fields: SupplierField[]) => {
const columns: Record<string, string[]> = { ...coreColumnAliases };
const values: Record<string, string[]> = { ...coreValueAliases };
for (const field of fields) {
if (field.headerAliases) {
columns[field.id] = field.headerAliases;
}
if ("valueAliases" in field && field.valueAliases) {
for (const [option, aliases] of Object.entries(field.valueAliases)) {
values[option] = aliases;
}
}
}
return { columns, values };
};

With that table in place all eighteen headers reach a column. The gain is not limited to the configured fields. Canada, Germany, Deutschland, Mexico, United Kingdom and United States reach nothing against a five-code country list on their own, and the core alias table lands all six. U.S. and usd already land without help, because an abbreviation sits closer to a code than a spelled-out name does.

An unmatched value does not arrive as itself. A select value survives the value-matching step only when it is mapped, so the cell lands empty. Without the alias table 913 country cells empty out that way, and 683 risk tiers, 571 statuses, 395 organization types and 119 currencies go with them. No rule fires on any of them. With the table in place, country, risk tier, organization type and currency all come back to the count of cells the file itself left blank.

Every way a system writes yes

Minority Indicator arrives as seventeen distinct strings, and 215 rows leave it empty. NetSuite publishes what its own vendor import accepts for a boolean, and the list is "True, true, TRUE, T, yes, Yes, YES". A file written to that spec carries every one of them.

Updog holds a built-in value table covering common categorical vocabularies, and it lands 13 of the 17 on Yes or No with no configuration. Everything spelled out arrives. true, True, TRUE, Y, yes, Yes, YES, false, False, FALSE, N, no and No all reach an option.

The four it leaves are T, F, 0 and 1, and 705 rows of this file sit on those four strings. They go in the valueAliases of the configured field, next to the label, where the customer's own answer already lives.

Pending and On Hold in the status column reach nothing either, and that result is correct. Neither is a spelling of active, inactive or draft. Only the buyer can say which one they meant, and the value-matching step is where they say it.

The identifier that lost a digit

A D-U-N-S Number is nine digits. The digits carry no meaning, so about one identifier in ten opens with a zero, and Excel drops that zero on the way in. Dun & Bradstreet publishes the problem in its own help centre. "Leading zeros in number fields such as zip code and D-U-N-S Number are not visible in Excel unless you apply custom formatting."

In this file 219 rows carry eight digits where nine were written. Another 161 carry the number with dashes in it. A regex rule reports all 380.

What arrives Rows What the rule does
nine digits 2,431 passes
eight digits 219 flagged
15-184-8574 161 flagged
empty 309 passes

The 309 empty cells are the part worth reading twice. A regex has nothing to test against an empty string, so a supplier with no D-U-N-S at all travels through in silence. Whether an empty D-U-N-S blocks a supplier is a platform decision, and { type: "required" } beside the regex is where you write it down.

A term your option list does not hold

Payment terms have to land on a list your system already holds. Coupa states it as a rule for its supplier import, "Must match existing payment terms", and NetSuite states the same thing the other way round. "You must create payment terms prior to referencing them."

Thirteen distinct terms arrive against six options. Eleven of them land on their own. NET30, Net30, N30 and 30 days all reach Net 30, which is right. COD and Immediate reach nothing, and those are decisions for the buyer. 213 rows carry one of the two, and every one of them reaches the grid with an empty payment term.

One auto-match lands and is wrong. 2/10 Net 30 reaches Net 30 on 151 rows. The term means two percent off for paying inside ten days, and the option list holds no term that carries a discount. The value arrives looking settled, and nothing in the wizard flags it.

The fix is the option list. A closed select and a oneOf rule keep a value inside the set, and they cannot invent a set member that finance never created. When a term matters to a customer, that customer configures it, which is the same loop the rest of this schema runs on.

The separator this export uses

Plants Served holds a list in one cell, and this file writes that list three ways. 1,157 rows separate with a semicolon, 556 with a comma and a space, and 155 with a slash.

Updog detects the separator among comma, semicolon, pipe, newline and tab by checking which one splits cells into tokens that resemble your options. It picks the semicolon here, and it recovers the comma rows too, because every piece of those splits into a known plant.

The slash rows are the ones to watch. A slash is no candidate, so PLT-05 / PLT-12 stays whole, fuzzy-matches to PLT-05 and looks settled. 252 plant references disappear that way, and 146 strings reach the value-matching step where twelve belong.

Declaring the separator takes the column to twelve exact values with nothing lost, because the recovery rule still splits the semicolon and comma cells underneath. That is what separator: " / " is doing in the configuration object. The customer knows which system writes their files, and your code does not.

The rows that stop at a rule

Out of 3,120 rows, 840 carry something a rule caught.

Message Rows What is in the cell
Invalid email address 443 284 cells holding two addresses, 159 with no @
DUNS must be 9 digits 380 219 short, 161 with dashes
Value must be unique 48 24 supplier numbers written on two rows each
This field is required 40 an empty supplier number

The 24 repeated supplier numbers are the reason primaryKey names that column and the schema puts { type: "unique" } on it. Company names repeat as well, 486 of them, and a repeated legal name is ordinary. Two branches of one group trade under one name and settle their invoices apart.

One column raised nothing. Insurance Expiry arrives with 1,876 dates in ISO and 790 written 03/26/2026, and every one of them parsed.

A rule fires on what arrived. The cells that never arrived are the other half of the list. 484 rows reach the grid with no status, where the file left 133 blank. The 351 in between held Pending or On Hold, and the value-matching step dropped both because neither maps to an option. Payment terms behave the same way, 434 empty against 221 blank in the file.

An empty cell carries no error beside it. { type: "required" } on the columns a supplier record cannot go without is what turns that silence into one of the messages above, and which columns those are is a decision your platform makes.

What your handler receives

const fields = await loadSupplierFields(tenantId);
const columns = buildColumns(fields);
const synonyms = buildSynonyms(fields);
<DataEditor<Supplier>
apiKey={apiKey}
open={open}
onClose={close}
columns={columns}
synonyms={synonyms}
primaryKey="supplierNumber"
onComplete={async (result) => {
for (const source of result.sources) {
const rows = source.rows.filter((r) => r.isValid && !r.isDeleted);
await upsertSuppliers(tenantId, rows.map((r) => r.row));
}
}}
/>

onComplete hands back flat rows grouped by source, each carrying isNew, isChanged, isDeleted and isValid. The row shape follows the schema you built, so a configured field arrives under the id the customer's configuration gave it and your upsert writes it as a custom attribute.

primaryKey is a single column here, since a supplier number identifies a supplier inside one tenant. A file whose numbers repeat across branches takes a list instead, and a row merges only when every part matches.

No supplier schema ships in the box

Updog ships no supplier template, no diversity certification list and no connector to any procurement suite. Updog Importer reads CSV, TSV, JSON, XML, XLSX, XLS, XLSB and ODS. A signed supplier questionnaire that arrives as a PDF or a scan needs a parser you supply, and its rows land in the grid beside the spreadsheet rows. The twelve core columns, the field mapper, the alias builder and every option list are yours to write and yours to keep.

The catalog side of procurement stays out too. A price file keyed on a supplier part number against your own item number is a different import with a different key, and a published catalog format like CIF reaches the drop zone the same way a PDF does, through a parser you write.

One thing in this file is worth a decision before you map it. A supplier export can carry a social security number, because a sole proprietor's tax number is their social security number. Leaving that column out of the schema is enough to drop it, since an unmapped header lands nowhere.

The same code at the second customer

Mount the same build against a second customer and the field array comes back different. Broker code, contract end, service line. Twelve of the eighteen headers in this same file reach a column, and the six that miss are Pay Group, Risk Tier, Insurance Expiry, Plants Served, Minority Indicator and Minority Type.

All six are fields this customer does not keep. Nothing is broken. A header with nowhere to land is the correct answer when the customer never asked for the field, and the person sees it sitting unmapped and moves on.

That is the whole shape of a procurement import. The file comes from a system you do not control, the schema comes from a customer you have not met, and the code you write is the function between them.