Import Data from CSV
Build upload, mapping, preview, review, and confirmation workflows
Last Updated: 2026-09-04
Version: 1.11
CSV imports let a user review bank-record, owner, vendor, expense, or deposit files with different column layouts before any records are written. The API owns file inspection, preset detection, mapping contracts, validation, and processing. The UI owns the interaction around those API responses.
Use only the top-level /csv-imports routes for this workflow. The synchronous
Connection CSV endpoint is for existing fixed templates and has a separate
contract.
All requests use the normal API host and selected team:
x-api-key: <team-api-key>
x-team-id: <team-uuid>See Authentication for other supported credentials.
Supported Resources
| Resource | Logical record | Supported CSV shape |
|---|---|---|
| Bank records | One CSV row | Wide |
| Owners | One owner, optionally linked to a listing | Wide |
| Vendors | One vendor per row | Wide |
| Expenses | Rows grouped by bill reference | Wide or repeated lines |
| Deposits | Rows grouped by deposit reference | Wide or repeated lines |
Read GET /csv-imports/resources/{resource} before rendering mapping fields.
The response is the versioned contract for detection, mapping requirements,
row requirements, fixed values, and import effects. Do not copy these rules
into the client.
GET /csv-imports/resources/owners{
"resource": "owners",
"version": 1,
"label": "Owners",
"rowShapes": ["wide"],
"fields": [
{
"key": "ownerKind",
"label": "Owner kind",
"required": true,
"input": "column",
"detection": {
"aliases": ["Owner kind", "Kind", "Contact kind"],
"values": {
"individual": "individual",
"company": "company"
},
"deriveFrom": "companyType",
"derivedValues": {
"individual": "individual",
"c_corporation": "company"
}
}
},
{
"key": "companyType",
"label": "Company type",
"required": false,
"input": "column",
"detection": {
"aliases": ["Company type", "Business entity type", "Entity type"]
}
},
{
"key": "listing",
"label": "Listing",
"required": false,
"input": "column",
"detection": {
"aliases": ["Listing", "Listing ref", "Listing reference", "Property"]
}
}
],
"mappingRequirements": [
{ "type": "allOf", "fields": ["ownerKind"] }
],
"rowRequirements": [
{
"type": "when",
"field": "ownerKind",
"equals": "individual",
"require": "allOf",
"fields": ["firstName", "lastName"]
},
{
"type": "when",
"field": "ownerKind",
"equals": "company",
"require": "allOf",
"fields": ["companyName", "companyType"]
}
],
"fixedValues": [
{ "field": "type", "value": "owner" },
{ "field": "status", "value": "active" }
],
"effects": [
{
"when": { "field": "listing", "present": true },
"action": "createListingOwnership",
"startAt": "1970-01-01",
"endAt": null,
"businessModel": "managed",
"reserve": 0,
"split": "equal"
}
]
}The response orders fields for display. required is the compatibility view of
unconditional allOf mapping requirements. Use mappingRequirements and
rowRequirements for one-of and conditional rules. deriveFrom describes an
explicit detection rule; it is not a processor fallback. The example is
shortened. See the exact
resource metadata contract.
UI and API Responsibilities
| Concern | UI | API |
|---|---|---|
| File selection and progress | Render | Create upload and inspect bytes |
| Headers and delimiter | Display | Detect |
| Preset settings | Render from control metadata | Define and validate |
| Column mapping | Let the user edit | Suggest, validate, and apply |
| Reference matches | Let the user choose stable IDs | Find candidates and apply overrides |
| Preview totals and issues | Display and filter | Compute |
| Large result sets | Page on demand | Store and serve paginated artifacts |
| Import eligibility | Disable or explain | Enforce on confirmation |
| Record creation | Show progress and outcomes | Process idempotently |
Do not duplicate preset IDs, provider rules, or resource field catalogs in the client. Render the metadata returned by the API.
End-to-End Flow
| Step | Request | UI result |
|---|---|---|
| 1 | Read resource and preset metadata | Render supported fields and controls |
| 2 | Create an upload | Receive uploadRef, uploadUrl, and expiry |
| 3 | PUT the unchanged file | Receive headers, delimiter, and detection |
| 4 | Create a preview | Receive an operation acknowledgement |
| 5 | Poll and page preview records | Review valid, invalid, unresolved, and excluded records |
| 6 | Create another preview after edits | Review an immutable replacement plan |
| 7 | Confirm the selected preview | Receive an operation acknowledgement |
| 8 | Poll and page import outcomes | Show created, updated, existing, skipped, and failed records |
The API stores large files, preview plans, and import outcomes. The UI keeps only IDs, summaries, current form state, and the page being displayed.
1. Read Presets
Call GET /csv-imports/presets?resource=deposits when the user selects a
resource. A preset definition includes its configuration controls. The client
must not branch on values such as
stripe.payout-reconciliation.
{
"data": [
{
"id": "stripe.payout-reconciliation",
"version": 1,
"resource": "deposits",
"label": "Stripe payout reconciliation",
"description": "Import Stripe payout reconciliation exports as grouped deposits.",
"variants": ["Stripe payout reconciliation report"],
"settings": [
{
"key": "bankAccountId",
"label": "Bank account",
"description": "Account that received the Stripe payout.",
"control": "account",
"required": true,
"accountPurpose": "bank"
}
]
}
]
}The Stripe preset exposes only the payout bank account. Its category and fee
lines use the configured transaction line mappings. Render every returned setting
by its control discriminator:
| Control | Expected UI |
|---|---|
account | One account selector filtered by accountPurpose |
accountMap | Source-key to account selectors |
string | Text input |
percentage | Numeric input bounded by minimum and maximum |
currencyMoneyMap | Currency-key to money inputs |
Use inspection candidate suggestions to seed map keys or values. A preset
version is part of its identity. If the selected candidate ID or version
changes, discard the previous candidate's setting values and initialize the
new controls again.
See the exact preset metadata contract.
2. Upload and Inspect
Create the upload descriptor first:
POST /csv-imports/uploads
Content-Type: application/json
{
"fileName": "stripe-payout.csv",
"fileSize": 48210,
"resource": "deposits"
}{
"expiresAt": "2026-08-24T14:30:00.000+00:00",
"uploadRef": "upload-ref",
"uploadUrl": "https://api.vrplatform.app/csv-imports/uploads/upload-ref"
}Send the file bytes unchanged to the returned URL. Do not JSON-encode the file or parse it in the browser.
PUT <uploadUrl>
Content-Type: text/csv
<raw CSV bytes>{
"status": "uploaded",
"inspection": {
"encoding": "utf-8",
"delimiter": ",",
"headerRow": 1,
"headers": [
"automatic_payout_id",
"automatic_payout_effective_at",
"balance_transaction_id",
"reporting_category",
"gross",
"fee",
"net",
"currency",
"description"
],
"detection": {
"state": "exact",
"candidates": [
{
"id": "stripe.payout-reconciliation",
"version": 1,
"resource": "deposits",
"label": "Stripe payout reconciliation",
"suggestions": {},
"evidence": {
"forbiddenHeaders": [],
"matchedHeaders": [
"automatic_payout_id",
"automatic_payout_effective_at",
"balance_transaction_id",
"reporting_category",
"gross",
"fee",
"net",
"currency",
"description"
],
"missingHeaders": []
}
}
]
}
}
}Detection guides the choice; it does not import anything.
| Detection state | UI behavior |
|---|---|
exact | Preselect the candidate and ask the user to confirm its settings |
possible | Offer the candidate alongside column mapping |
ambiguous | Require the user to choose a candidate or column mapping |
none | Continue with column mapping |
Files must be UTF-8 and may contain at most 100,000,000 bytes, 100 columns, and 1,000,000 data rows. A physical CSV record may contain at most 256 KiB. Grouped owner records may contain 100 rows; other grouped records may contain 1,000 rows. Uploads expire after 24 hours.
The upload reference retains the selected resource. Do not resend it when uploading bytes, inspecting the upload, or creating a preview.
GET /csv-imports/uploads/{uploadRef} repeats inspection while the upload
remains available.
See create upload, upload bytes, and inspect upload.
3. Choose a Preview Selection
Every preview uses one of three selection contracts.
Automatic mapping
Use automatic mapping as the first mapping attempt. Bank records additionally require the selected bank account.
{
"uploadRef": "upload-ref",
"selection": {
"type": "detection"
}
}When completed, result.details.mapping contains the API's mapping proposal.
Render it in the mapping form. Submit an edited mapping as a new preview.
Preset
Send the selected candidate's exact ID and version plus values for the settings returned by preset metadata.
{
"uploadRef": "upload-ref",
"selection": {
"type": "preset",
"id": "stripe.payout-reconciliation",
"version": 1,
"configuration": {
"bankAccountId": "11111111-1111-4111-8111-111111111111"
}
}
}The API reads fees from the export. It does not ask the UI to reconstruct or split Stripe fees.
Explicit mapping
Mapping contracts differ by resource. This bank-record example maps one signed amount column:
{
"uploadRef": "upload-ref",
"selection": {
"type": "mapping",
"accountId": "11111111-1111-4111-8111-111111111111",
"mapping": {
"resource": "bankRecords",
"version": 1,
"delimiter": ",",
"headerRow": 1,
"excludedRows": [],
"date": {
"source": "Transaction date",
"format": "yyyy-mm-dd"
},
"description": {
"sources": ["Description"],
"operation": "join"
},
"amount": {
"mode": "signed",
"source": "Amount",
"debitSign": -1,
"creditSign": 1
}
}
}
}For owner, vendor, expense, and deposit mappings, each field carries its source column and any value or stable-ID overrides. Use the generated create preview contract for the exact resource-specific schemas.
Changing a mapping, excluded row, preset configuration, or reference override
does not mutate an existing preview. Post the same uploadRef with the new
selection and review the new preview ID.
4. Poll and Review the Preview
Creating a preview returns immediately:
{
"operationId": "33333333-3333-4333-8333-333333333333",
"type": "csv-import-preview",
"status": "queued",
"resource": null
}Use operationId as the preview ID. Poll
GET /csv-imports/previews/{id} while the status is queued or running.
A completed response contains a bounded summary, not every record:
{
"id": "33333333-3333-4333-8333-333333333333",
"status": "completed",
"failure": null,
"result": {
"logicalRecordCount": 2450,
"stateCounts": {
"valid": 2431,
"invalid": 3,
"unresolved": 12,
"excluded": 4
},
"issueCounts": {
"listingNotFound": 12,
"invalidAmount": 3
},
"fileHash": "c6b06de0c87b0e3f899aec6147aa8d32dc764fda90ec9767dd90ea9657e381aa",
"planHash": "f88b4f13a15a3cfc7b72c80ec52dfa6a7fe12bcc640eec93c4ac74239d25f28f"
}
}Operation status uses the shared lifecycle vocabulary:
| Status | UI behavior |
|---|---|
queued or running | Continue polling |
completed | Read the summary and records |
failed | Map failure.code to UI copy and allow recovery |
GET /operations/{id} returns the same status and failure. The CSV status
endpoint adds the completed preview result used by this workflow.
Use the counts for tabs and badges. Fetch table rows from the records endpoint:
The records query uses state for validation state. status is reserved for
the preview or import lifecycle.
GET /csv-imports/previews/33333333-3333-4333-8333-333333333333/records?state=unresolved&limit=100{
"data": [
{
"resource": "owners",
"recordRef": "row-14",
"rowNumbers": [14],
"state": "unresolved",
"source": [
{
"Listing": "Beach House",
"Owner email": "owner@example.com"
}
],
"resolutions": [
{
"field": "listing",
"sourceValue": "Beach House",
"state": "ambiguous",
"candidates": [
{
"id": "44444444-4444-4444-8444-444444444444",
"label": "Beach House",
"secondaryLabel": "Miami, FL"
}
],
"candidateCount": 2,
"candidatesTruncated": true
}
],
"excluded": false,
"issues": [
{
"code": "referenceUnresolved",
"field": "listing",
"rowNumber": 14,
"message": "Listing reference is unresolved"
}
]
}
],
"nextCursor": "<opaque-cursor>"
}Pass nextCursor unchanged to fetch the next page. The maximum page size is
250. A cursor belongs to its preview, state filter, and page size. Do not build
cursors or keep all preview records in client memory.
To resolve the example, add an override from Beach House to the selected
candidate UUID in the mapping and create a new preview. Candidate labels are
for display; send the stable candidate ID.
| Record state | Meaning | Blocks confirmation |
|---|---|---|
valid | Ready to import | No |
invalid | A value or relationship violates the contract | Yes |
unresolved | A required reference needs a stable-ID choice | Yes |
excluded | Intentionally outside the import plan | No |
Confirmation also requires at least one valid record. See preview status and preview records.
5. Confirm and Read Outcomes
Confirm the reviewed preview without resending its mapping or file:
POST /csv-imports/previews/33333333-3333-4333-8333-333333333333/confirm
Content-Type: application/json{}{
"operationId": "55555555-5555-4555-8555-555555555555",
"type": "csv-import",
"status": "queued",
"resource": null
}Use operationId as the import ID. Repeated confirmation of the same preview
returns the same import. Poll GET /csv-imports/{id} until it reaches a final
status.
Confirming another preview for the same exact file and target returns a conflict after a completed import. To intentionally import it again, send:
{
"duplicateOverride": {
"reason": "intentionalReimport"
}
}If the prior exact attempt is queued, started, failed, or canceled, confirmation returns that attempt. Send the override only when another attempt is intended.
Exact-file identity uses the raw file hash and target scope. For bank records, the target is the selected bank account. Similar normalized rows remain warnings and are never merged automatically.
{
"id": "55555555-5555-4555-8555-555555555555",
"status": "completed",
"failure": null,
"outcome": {
"created": 2410,
"updated": 18,
"existing": 3,
"skipped": 0,
"failed": 0
}
}Page detailed outcomes when the user needs record-level results:
GET /csv-imports/55555555-5555-4555-8555-555555555555/records?state=created&limit=100{
"data": [
{
"recordRef": "row-14",
"rowNumbers": [14],
"state": "created",
"entityIds": ["66666666-6666-4666-8666-666666666666"],
"issues": []
}
],
"nextCursor": null
}See confirm preview, import status, and import records.
Failure Contract
CSV failures use three complementary contracts:
| Layer | Read | Use |
|---|---|---|
| Request | code, context.reason, and optional issues | Choose recovery and mark fields |
| Preview or import | status: "failed" and failure | Explain an asynchronous failure |
| Record | state and issues[] | Fix, resolve, or exclude one record |
Request failures use the standard error contract.
Branch on code and context.reason; never parse message.
{
"code": "BAD_REQUEST",
"message": "CSV preset configuration is invalid",
"issues": [
{
"message": "Invalid UUID",
"path": ["selection", "configuration", "bankAccountId"]
}
],
"context": { "reason": "presetConfigurationInvalid" }
}| Reason | Code | Recovery |
|---|---|---|
uploadTooLarge, selectionTooLarge | PAYLOAD_TOO_LARGE | Select a smaller file or mapping |
uploadUnavailable | NOT_FOUND | Create and upload a new file reference |
csvInvalidEncoding | BAD_REQUEST | Export the file as UTF-8 |
csvNoRows | BAD_REQUEST | Add data rows and upload the file again |
csvBlankHeader, csvDuplicateHeaders | BAD_REQUEST | Correct the headers and upload again |
csvColumnLimitExceeded | BAD_REQUEST | Reduce the number of columns |
csvRowLimitExceeded | BAD_REQUEST | Reduce the number of rows |
presetConfigurationInvalid | BAD_REQUEST | Mark fields from issues and keep the setup |
presetSourceMismatch | CONFLICT | Select a detected preset or use mapping |
previewNotReady | CONFLICT | Continue polling before confirmation |
previewBlockingIssues | CONFLICT | Resolve or exclude blocking records |
previewNoImportableRecords | CONFLICT | Include at least one valid record |
previewStale, csvSourceChanged | CONFLICT | Create a new preview from the current file |
csvExactFileDuplicate | CONFLICT | Reuse it or confirm with an intentional override |
previewExpired | GONE | Start a new workflow |
importOutcomesExpired | GONE | Stop paging outcomes |
previewRecordCursorInvalid | BAD_REQUEST | Restart preview paging |
importOutcomeCursorInvalid | BAD_REQUEST | Restart outcome paging |
The exact reason union for each request is included in its OpenAPI response.
Asynchronous preview source failures use csvFileTooLarge,
csvColumnLimitExceeded, csvRowLimitExceeded, csvLogicalRecordTooLarge,
csvInvalidEncoding, csvNoRows, csvParsingFailed, csvSourceChanged, or
csvSourceUnavailable.
| Asynchronous code | Recovery |
|---|---|
csvPreviewTooLarge | Split the file and create a new preview |
csvPreviewStorageFailed | Retry a new preview from the uploaded file |
csvImportPlanUnavailable | Create a new preview |
csvImportPlanInvalid | Create a new preview |
csvImportApplyFailed | Review target records before retrying |
csvImportOutcomeUnavailable | Review target records; do not retry blindly |
csvImportRecordFailed | Page the failed outcome records |
operationFailed | Review target records and contact support if repeated |
The import may have written records when its code is csvImportApplyFailed,
csvImportOutcomeUnavailable, or operationFailed. Do not automatically retry
those operations. The API validates the complete reviewed plan before its first
resource write. An internally canceled job is exposed through this operation
surface as status: "failed" with failure.code: "operationFailed".
{
"id": "33333333-3333-4333-8333-333333333333",
"status": "failed",
"failure": {
"code": "csvInvalidEncoding",
"message": "CSV must use UTF-8 encoding"
}
}Keep the upload and selection after a preview failure so the user can correct
the problem. Keep the import ID after an import failure so its final status is
not lost. Request import records only when the status response includes
outcome; a terminal status without totals has no readable outcome manifest.
UI State and Recovery
Keep the workflow explicit. A modal with one undifferentiated loading state is not enough for a durable asynchronous import.
| UI state | Keep | Primary action |
|---|---|---|
| File selected | Local file and resource | Start upload |
| Configuring | uploadRef, inspection, mapping or preset settings | Preview |
| Previewing | previewId and current setup | Poll status |
| Reviewing | Summary, current page, setup | Fix or confirm |
| Importing | importId | Poll status |
| Completed | Outcome summary and filters | View records or finish |
| Preview failed | Upload and setup | Correct and create another preview |
| Import status unknown | importId | Read status before any retry |
Apply these client rules:
- Replacing the file clears its
uploadRef, candidate, settings, mapping, preview, and import state. - A preview error must not discard the uploaded file or the user's setup.
- Changing a preset candidate or version reinitializes its settings.
- Disable repeated upload and confirmation submissions while their request is active.
- Prevent accidental modal closure while file upload or confirmation is in flight, or require explicit confirmation before abandoning it.
- If confirmation loses its response, read the known preview or import status. Do not create a second workflow automatically.
- Page preview and outcome records. Do not download the complete result set into the browser.
- Render API issue messages and fields beside the affected record. Use issue codes for UI behavior, not for user-facing copy.
Resource Rules
| Resource | Rules that affect the UI |
|---|---|
| Bank records | Select one account. Pre-start rows are locked exclusions and are not imported. |
| Owners | Creates owners and optional ownership from 1970-01-01 with no end date. |
| Vendors | Creates active vendors, updates exact matches, and never creates ownership. |
| Expenses | Repeated bill references form one expense with multiple lines. |
| Deposits | Repeated references form one deposit with multiple lines. |
The CSV preview behavior differs from direct or feed ingestion. A reviewed pre-start CSV row stays in the preview as a system exclusion and creates no BankRecord. A pre-start row received through an AccountConnection or direct bank-record batch is stored as inactive history. Neither path changes opening balance or active bank totals.
An owner row without a listing creates or updates only the owner contact. A row
with a listing also creates ownership from 1970-01-01. Owner kind can come from
an explicit individual/company mapping or from Company Type: individual
selects an individual, while a mapped entity type selects a company. The owners
resource always writes an owner contact, regardless of a source column named
Type.
A generic Type header is not detected as owner kind or vendor kind. Use an
explicit kind column, a constant, or the documented owner derivation. Automatic
expense and deposit mapping infers date format from sampled values. Ambiguous
slash dates such as 04/09/2026 require the user to select dd/mm/yyyy or
mm/dd/yyyy; the API does not assume ISO format.
Owner imports do not configure ownership percentages, dates, reserves, or business models. Existing unrelated ownership can block the affected listing. Name-only owner and vendor matches remain suggestions until reviewed. Blank CSV contact values do not clear existing contact fields.
Expenses and deposits accept either amounts on one wide row or repeated line rows under the same reference. The API normalizes both forms into one logical transaction before previewing it.
Fixed-Template Boundary
POST /connections/{id}/csv-import remains the synchronous endpoint for
existing fixed templates. It accepts its documented fixed-template inputs and
does not accept upload, mapping, preview, confirmation, status, or
reviewed-record fields.
New CSV import UI work must use /csv-imports. Do not add compatibility branches
to the Connection endpoint.
