Skip to content

Import vendors and contracts

Moving a register in from a spreadsheet, another GRC tool or an ERP export. Vendors have a bulk import endpoint; contracts do not — and the second half of that sentence is the part worth reading before you plan a migration.

What you are loading Route
Vendors POST /v1/vendors/imports — this guide
Contracts the CSV / XLSX importer in the app, not the API
Documents onto an existing contract POST /v1/contracts/{id}/documents

One request carries the rows and the options. Cells are strings, keyed by importable field name:

Terminal window
curl -X POST https://api.vendorica.com/v1/vendors/imports \
-H "Authorization: Bearer $VENDORICA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "Q3 ERP export",
"matchKey": { "key": "lei", "mode": "upsert" },
"enrich": true,
"vendors": [
{ "name": "Acme BV", "lei": "724500VKKSH9QOLTFR81", "website": "acme.example" },
{ "name": "Globex GmbH", "vatNumber": "DE123456789" }
]
}'

Scope: vendors:write. Up to 10,000 rows per request.

It runs the same validate → dedupe → commit pipeline as the in-app wizard and mints an IMP-{n} history entry, so an API import and a spreadsheet import land in the same audit record and are reviewed the same way.

The response tells you what happened, per row

Section titled “The response tells you what happened, per row”

The call is synchronous — the outcome is in the response body, not behind a job you have to poll:

{
"success": true,
"data": {
"job": {
"id": "f00e1550-382b-4016-8193-d10f06b21451",
"displayId": "IMP-42",
"status": "completed",
"rowsTotal": 2,
"rowsImported": 1,
"rowsUpdated": 1,
"rowsSkipped": 0,
"rowsFailed": 0
},
"summary": { "imported": 1, "updated": 1, "skipped": 0, "failed": 0 }
}
}

failed is not an error status — a request can return 200 with rows that did not land. Check summary.failed, not just the HTTP code.

matchKey.key is one of name, website, vatNumber or lei; mode is create-only (default) or upsert.

Prefer lei where your data has it: it is the only one of the four that is a registered identifier rather than a string someone typed. name is the default because every file has one, and it is also the weakest — “Acme BV”, “Acme B.V.” and “ACME BV” are three vendors to a matcher and one to you.

enrich: true fills an empty countryOfRegistration from GLEIF for rows whose LEI passes its checksum. Bounded and best-effort: it never overwrites a value you supplied, and a GLEIF timeout costs you the enrichment, not the import.

The importer in the app (Contracts → Import) takes CSV or XLSX, supports the same upsert semantics, and matches each row to a vendor.

Two things that catch people out, both deliberate:

  • vendorName must be mapped, even in a file where every row carries an LEI. The LEI still resolves the vendor at validation — the mapping requirement is there to keep the name column honest against it.
  • There is no “create missing vendors” toggle. A row whose vendor does not resolve is a row error, pointing you at the vendor importer. This is why the order in a migration is vendors first, contracts second.

If you match on title, be aware it can false-positive across vendors — two suppliers both filing “Master Services Agreement”. Use arrangementReference where you have one.

POST /v1/vendors/imports requires the vendors.bulk_import and vendors.import_integrations features. Without them the call returns 402, not 403 — the request was understood and authorised, and the plan is what refused it.

An import request is a single write, so the write budget is not usually what binds. What does, on a migration that also carries files, is the upload budget: 60/min, and it stacks with writes.

For anything at migration scale, ask support to raise the budget on your API client before you start. That is what the per-client override exists for, and it is a faster conversation before the first 429 than after it. See rate limits.