← All posts

2026-07-29

CSV Client Import for Real Shops: Mapping Vagaro, Booksy, and Square Without Tears

CSVImportTypeScriptSaaSBizee

Bizee CSV client import mappers for Vagaro Booksy Square cover

Part of Building Bizee — Technical Notes. See also the Bizee case study.

Introduction

The hook

A salon owner won’t retype 800 clients. They export a CSV from Vagaro, Booksy, Square, or Google Contacts — each with different column names, phone formats, and “full name” vs first/last splits.

If import fails on row 47, they leave. Bizee needed self-serve CSV import that respects how competitors actually export.

The objective

You will learn how to:

  1. Normalize messy CSV rows into one internal shape.
  2. Plug in per-competitor mappers without rewriting the import API.
  3. Design an import UX that previews, reports errors, and leads to the next action (booking link).

Prerequisites

  • TypeScript comfort
  • A backend that can accept uploads or parsed rows
  • Basic CSV parsing (headers + records)
  • Empathy for non-technical operators

Body

Step 1: Define one internal row contract

Everything maps into your shape — never the other way around mid-pipeline.

export interface MappedImportRow {
  input: ImportClientRowInput; // firstName/lastName or name, email, phone, …
  mappedHeaders: string[];     // which CSV columns we actually used
}

Downstream create/upsert only speaks MappedImportRow.input. mappedHeaders powers preview UI (“we mapped these columns”).

Step 2: One mapper per export dialect

// Pattern from clientImportMappers.ts
export function mapVagaroRow(record: Record<string, string>): MappedImportRow {
  // fuzzy-match headers like "Customer First Name", "Mobile Phone"
  // → { input, mappedHeaders }
}

export function mapBooksyRow(record: Record<string, string>): MappedImportRow {
  // Booksy often ships a single "Client" / "Name" column
}

export function mapSquareRow(record: Record<string, string>): MappedImportRow { /* … */ }
export function mapGoogleContactsRow(record: Record<string, string>): MappedImportRow { /* … */ }

// Dispatcher picks the dialect the owner selected (or you auto-detected)
switch (source) {
  case 'vagaro': return mapVagaroRow(record);
  case 'booksy': return mapBooksyRow(record);
  case 'square': return mapSquareRow(record);
  default: return mapGenericRow(record);
}

Adding a new competitor = new function + switch case + fixtures. The API stays boring.

Step 3: Normalize phones and names aggressively

Real exports include:

  • (555) 123-4567 vs 5551234567 vs +1-555-123-4567
  • "Jordan Lee" in one column vs First + Last
  • Empty emails with phones only (still valuable)

Normalize before uniqueness checks. Decide merge keys (email first, else phone) and document them in the UI.

Step 4: UX is part of the engineering

Bizee’s import flow goals:

  1. Pick source template / auto-detect headers when possible.
  2. Preview mapped rows before commit.
  3. Show per-row errors without failing the whole file silently.
  4. After success: share booking link — import isn’t the end, reactivation is.

Step 5: Test the mappers like contracts

Unit-test each mapper with fixture CSVs (headers copied from real exports). E2E-test the dialog once; unit-test the dialects forever.


Conclusion

Summary

  • One internal row type; many mappers.
  • Normalize phones/names before dedupe.
  • Preview + partial success beats all-or-nothing.
  • Post-import CTA matters as much as parse success.

Call to Action

Owners import on /clients in the live product: bizee.life.
Written a CSV importer? Comment which competitor’s export was the messiest.

References