A secure, serverless tool to sync prospect pipeline statuses from a daily Juniper Square CSV export into Affinity.
- Upload a CSV via a protected UI
- We match names by type (Organization vs Person), exact → fuzzy, and via cross-field associations
- We derive the best Affinity pipeline status from Subscription/Data Room signals (with business rules)
- We write updates to an Affinity ranked-dropdown field without downgrading and never overwriting "Passed"
index.html— Single-page UI (Helena branding) to upload CSV, provide API key, and choose dry-runapi/upload.js— Serverless function (Vercel/Node 18) that parses CSV, matches rows to Affinity list entries, derives statuses, and updates Affinitypackage.json— Minimal deps for the API (axios,csv-parse,string-similarity)
- Node.js ≥ 18 (for local dev or Vercel CLI)
- Affinity v2 API token with access to the target list
- The Affinity list ID you want to update (configured in code as
LIST_ID)
npm install# Set environment variables (see below)
vercel dev
# UI at http://localhost:3000
# API at http://localhost:3000/api/upload- Recommended: Vercel
- Push to GitHub, connect project in Vercel
- Add environment variables in Vercel Project Settings → Environment Variables
- Deploy; the UI and API will be served from your Vercel domain
- Required
AFFINITY_V2_TOKEN— Affinity v2 Bearer token
- Recommended
CSV_SYNC_API_KEY— API key required by the UI/API (x-api-key, or Basic/Bearer). Prevents unauthorized uploads
- Optional (behavior/config)
STATUS_FIELD_NAME— Name of the dropdown field to update in Affinity list. Default:Status. If your field isPipeline Status, set this accordinglyMIN_STATUS_LABEL— Minimum pipeline stage required to act. Default:Invited to Data RoomMAX_CSV_BYTES— Upload size limit in bytes. Default:2000000REDACT_RESPONSE— If1, omits names/IDs from response payloadPREFER_ORGANIZATIONS— If1, bias ambiguous matches toward org entries
- Optional (labels/mapping)
STATUS_LABEL_TO_ID_JSON— Explicit mapping from status label → dropdown option ID (JSON object)STATUS_LABEL_ALIASES_JSON— Mapping of incoming labels/aliases → canonical labels (JSON object)SUB_STATUS_TO_STAGE_JSON— Mapping from Juniper Square Subscription Status phrases → Affinity stage labels (JSON object)NICKNAME_ALIASES_JSON— Mapping of canonical first names → nickname variants (JSON object:{ "matthew": ["matt"] })
- Optional (auth alternatives)
BASIC_AUTH_USER,BASIC_AUTH_PASS— If set, Basic auth is accepted in addition tox-api-key
The tool is robust to column names, but these are primarily used:
- Names
OrganizationContacts(semicolon- or bullet-separated allowed)
- Status signals
Subscription StatusData room grantedData room last accessedData room access detail(semicolon-separated segments, e.g., "Name: not yet accessed" or "Name: Aug 19, 2025")Prospect Status(low-resolution; used as a fallback only)Latest update(free text; used for hints)
We avoid org↔person mismatches and handle common naming issues.
- Indexes
- Organizations: normalized
entity.namewith legal suffixes stripped (Inc, LLC, Ltd, LLP, BV, GmbH, SARL, etc.) - People:
first_name/last_namewhen available, otherwiseentity.name. Also index nickname variants (e.g., Matthew↔Matt) - Associations: We fetch People/Organization fields from the Affinity list and index associated names per entry (and we add the entry’s own name into its association set)
- Organizations: normalized
- Matching order
- Pair match (CSV Organization + Contacts) against Affinity associations (exact → fuzzy)
- Association fallback: if the contact exists only in People field of an org entry (or vice-versa), resolve via association indices
- Type-only matching (organization-first, then people) exact → fuzzy
- Fuzzy thresholds
- Org: 0.88 (or 0.90+ when validating pairs)
- Person: 0.85 (or 0.90+ when validating pairs)
- Normalization
- Lowercase, accent removal, punctuation/whitespace cleanup
- Legal suffix stripping for orgs
- Nickname expansions for first names (overridable via
NICKNAME_ALIASES_JSON)
We derive a single Affinity pipeline stage per row with the following priority:
- Subscription Status (highest priority)
- Examples (customizable via
SUB_STATUS_TO_STAGE_JSON):Countersigned,Fully executed,Signed→Sub Docs SignedAwaiting investor signature,Staff review: pending→Ready for Sub DocsStarted,Draft,Invited→Sub Docs Sent
- Examples (customizable via
- Data room signals
Data room access detail→ if any segment is not "not yet accessed" and is date-like, setData Room Accessed / NDA ExecutedData room last accessed→ if present and not "not yet accessed", setData Room Accessed / NDA ExecutedData room granted→ if granted/yes, setInvited to Data Room
- Fallback hints
- Ignore
soft-circledas authoritative - Use
Latest update/Prospect Statusfor early stages likeIntro/First Meeting,Deck & PPM Sent, etc.
- Ignore
- Never downgrade: we won’t move backwards in the ordered pipeline
- Hard lock: if current status is
Passed(or configured synonyms), do not change - Minimum threshold: skip updates below
MIN_STATUS_LABEL(defaultInvited to Data Room)
- We update a ranked-dropdown list field via v2 API:
POST /v2/lists/{listId}/list-entries/{entryId}/fields/{fieldId} - Payload format:
{
"value": {
"type": "ranked-dropdown",
"data": { "dropdownOptionId": 123456 }
}
}- The correct
dropdownOptionIdis resolved by:- Reading the field’s options from the list (or learning options by scanning entries),
- Applying
STATUS_LABEL_ALIASES_JSON, and/or - Using
STATUS_LABEL_TO_ID_JSONoverrides when necessary
- Navigate to your deployed domain
- Enter the API key (required)
- Toggle Dry Run for a preview (no writes)
- Drag & drop or choose your CSV file, then Upload & Sync
- Review the result card for
ok,total, and per-row outcomes (includesmatchType,score, andreason)
# Dry run
curl -X POST \
-H "Content-Type: text/csv" \
-H "x-api-key: $CSV_SYNC_API_KEY" \
--data-binary @/path/to/export.csv \
"https://<your-vercel-domain>/api/upload?dry=1"
# Actual write
curl -X POST \
-H "Content-Type: text/csv" \
-H "x-api-key: $CSV_SYNC_API_KEY" \
--data-binary @/path/to/export.csv \
"https://<your-vercel-domain>/api/upload"- 401 Unauthorized
- Set
CSV_SYNC_API_KEYin the server environment and include it asx-api-keyheader (or configure Basic/Bearer)
- Set
- 413 CSV too large
- Reduce CSV size or increase
MAX_CSV_BYTES
- Reduce CSV size or increase
- "Unknown status '' for field ''"
- Ensure
STATUS_FIELD_NAMEmatches your actual Affinity field (e.g.,Pipeline Status) - Provide
STATUS_LABEL_TO_ID_JSONmapping, e.g.:
- Ensure
{
"Invited to Data Room": 19878021,
"Data Room Accessed / NDA Executed": 19878022,
"Ready for Sub Docs": 19878023,
"Sub Docs Sent": 19878024,
"Sub Docs Signed": 19878025,
"Committed": 19878026,
"Passed": 19878035
}- Optionally add
STATUS_LABEL_ALIASES_JSONto translate synonyms to exact labels - Match failures (
matched: false,No suitable org/person match)- Verify the CSV
OrganizationandContactsvalues - Confirm the Affinity list entries contain People/Organization associations
- Tune thresholds or add nickname aliases via
NICKNAME_ALIASES_JSON
- Verify the CSV
- "The I/O read operation failed" on Vercel
- Ensure
Content-Type: text/csvis set and the upload size is withinMAX_CSV_BYTES
- Ensure
- API requires an API key (recommended) and sets
Cache-Control: no-store - Size-limited uploads (
MAX_CSV_BYTES) - Optional response redaction (
REDACT_RESPONSE=1) - Keep
AFFINITY_V2_TOKENandCSV_SYNC_API_KEYin your server env (Vercel project settings)
- This tool is designed for one Affinity list (
LIST_IDis set in code). If you need to support multiple lists, consider parameterizing the list and field IDs per deployment - If you change your pipeline labels or add stages in Affinity, update
STATUS_LABEL_TO_ID_JSON(or rely on auto-learn + aliases) to keep mapping accurate