VRPlatformVRPlatform
Build a Product UI

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

ResourceLogical recordSupported CSV shape
Bank recordsOne CSV rowWide
OwnersOne owner, optionally linked to a listingWide
VendorsOne vendor per rowWide
ExpensesRows grouped by bill referenceWide or repeated lines
DepositsRows grouped by deposit referenceWide 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

ConcernUIAPI
File selection and progressRenderCreate upload and inspect bytes
Headers and delimiterDisplayDetect
Preset settingsRender from control metadataDefine and validate
Column mappingLet the user editSuggest, validate, and apply
Reference matchesLet the user choose stable IDsFind candidates and apply overrides
Preview totals and issuesDisplay and filterCompute
Large result setsPage on demandStore and serve paginated artifacts
Import eligibilityDisable or explainEnforce on confirmation
Record creationShow progress and outcomesProcess 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

StepRequestUI result
1Read resource and preset metadataRender supported fields and controls
2Create an uploadReceive uploadRef, uploadUrl, and expiry
3PUT the unchanged fileReceive headers, delimiter, and detection
4Create a previewReceive an operation acknowledgement
5Poll and page preview recordsReview valid, invalid, unresolved, and excluded records
6Create another preview after editsReview an immutable replacement plan
7Confirm the selected previewReceive an operation acknowledgement
8Poll and page import outcomesShow 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:

ControlExpected UI
accountOne account selector filtered by accountPurpose
accountMapSource-key to account selectors
stringText input
percentageNumeric input bounded by minimum and maximum
currencyMoneyMapCurrency-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 stateUI behavior
exactPreselect the candidate and ask the user to confirm its settings
possibleOffer the candidate alongside column mapping
ambiguousRequire the user to choose a candidate or column mapping
noneContinue 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:

StatusUI behavior
queued or runningContinue polling
completedRead the summary and records
failedMap 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 stateMeaningBlocks confirmation
validReady to importNo
invalidA value or relationship violates the contractYes
unresolvedA required reference needs a stable-ID choiceYes
excludedIntentionally outside the import planNo

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:

LayerReadUse
Requestcode, context.reason, and optional issuesChoose recovery and mark fields
Preview or importstatus: "failed" and failureExplain an asynchronous failure
Recordstate 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" }
}
ReasonCodeRecovery
uploadTooLarge, selectionTooLargePAYLOAD_TOO_LARGESelect a smaller file or mapping
uploadUnavailableNOT_FOUNDCreate and upload a new file reference
csvInvalidEncodingBAD_REQUESTExport the file as UTF-8
csvNoRowsBAD_REQUESTAdd data rows and upload the file again
csvBlankHeader, csvDuplicateHeadersBAD_REQUESTCorrect the headers and upload again
csvColumnLimitExceededBAD_REQUESTReduce the number of columns
csvRowLimitExceededBAD_REQUESTReduce the number of rows
presetConfigurationInvalidBAD_REQUESTMark fields from issues and keep the setup
presetSourceMismatchCONFLICTSelect a detected preset or use mapping
previewNotReadyCONFLICTContinue polling before confirmation
previewBlockingIssuesCONFLICTResolve or exclude blocking records
previewNoImportableRecordsCONFLICTInclude at least one valid record
previewStale, csvSourceChangedCONFLICTCreate a new preview from the current file
csvExactFileDuplicateCONFLICTReuse it or confirm with an intentional override
previewExpiredGONEStart a new workflow
importOutcomesExpiredGONEStop paging outcomes
previewRecordCursorInvalidBAD_REQUESTRestart preview paging
importOutcomeCursorInvalidBAD_REQUESTRestart 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 codeRecovery
csvPreviewTooLargeSplit the file and create a new preview
csvPreviewStorageFailedRetry a new preview from the uploaded file
csvImportPlanUnavailableCreate a new preview
csvImportPlanInvalidCreate a new preview
csvImportApplyFailedReview target records before retrying
csvImportOutcomeUnavailableReview target records; do not retry blindly
csvImportRecordFailedPage the failed outcome records
operationFailedReview 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 stateKeepPrimary action
File selectedLocal file and resourceStart upload
ConfiguringuploadRef, inspection, mapping or preset settingsPreview
PreviewingpreviewId and current setupPoll status
ReviewingSummary, current page, setupFix or confirm
ImportingimportIdPoll status
CompletedOutcome summary and filtersView records or finish
Preview failedUpload and setupCorrect and create another preview
Import status unknownimportIdRead 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

ResourceRules that affect the UI
Bank recordsSelect one account. Pre-start rows are locked exclusions and are not imported.
OwnersCreates owners and optional ownership from 1970-01-01 with no end date.
VendorsCreates active vendors, updates exact matches, and never creates ownership.
ExpensesRepeated bill references form one expense with multiple lines.
DepositsRepeated 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.

API Reference

On this page