👤CSV→VCF

JSON to VCF Converter — From an API Export to Phone Contacts

For developers and no-code users whose contacts already live as JSON — a CRM or API export, an Airtable or Notion JSON dump, a scraped list, a script's output — and who need those people on a phone or in Outlook. Drop the file here; keys are mapped to name, phone and email in the browser, and you download a .vcf. Nothing is uploaded.

👤
Written by Casey Marlin · Last updated
This update: JSON shapes covered by scripts/test-json.mjs; keys go through the same header detection as CSV

Drop your CSV, Excel or JSON file here

.csv, .xlsx, .xls or .json — up to 10 MB / 50,000 contacts, never uploaded.

What JSON shape works

The converter looks for an array of objects, one object per contact. The simplest file is a top-level array. This three-contact list is enough to produce a usable vCard:

[
  {"name":"Ada Lovelace","phone":"+1 555 0101","email":"ada@example.com","company":"Analytical Engines"},
  {"name":"Grace Hopper","phone":"+1 555 0102","email":"grace@example.com","company":"US Navy"},
  {"name":"Alan Turing","phone":"+1 555 0103","email":"alan@example.com","company":"Bletchley Park"}
]

Exports rarely look like that. Most wrap the array in an object. The code checks these wrapper keys in this exact order, and the first one that holds an array wins: contacts, then items, then data, then rows, then people, then results. So {"contacts":[…], "count":3} is read as the contacts list; count is ignored. {"people":[…]} works because people is on that list. If none of those names is present, there is a fallback: an object whose only key holds an array {"users":[…]} or {"records":[…]} — is accepted, because that single key is treated as the list. Two extra keys and no named wrapper (for example {"users":[…], "meta":{}}) is rejected: the converter will not guess which property is the contacts.

JSON shapes this converter accepts: a top-level array of objects, or an object whose array sits under contacts, items, data, rows, people or results in that order, or a single-key wrapper such as users or records; two extra keys with no named wrapper are rejected

The header row is the union of every object's keys, in first-seen order. A later object that omits a key gets an empty cell for that column; it is not dropped. A file that is not an array of objects at all — a single contact, a string, {"count":3} — shows the error Couldn't find a list of contacts in this JSON — expected [{...},{...}] or {"contacts":[...]}.

JSON shapeExampleWhat happens
Top-level array[{…},{…}]Each object is a contact.
Named wrapper{"contacts":[…], "count":3}First matching key wins — contacts, then items, data, rows, people, results. count is ignored.
Single-key object{"users":[…]}That one key is treated as the list.
Two extra keys, no named wrapper{"users":[…], "meta":{}}Rejected — the converter will not guess which property is the contacts.

Which keys map automatically

JSON keys are treated as CSV headers. They go through normalizeHeader in lib/mapping.js (lowercase, punctuation stripped) and match the same HEADER_RULES a Google or Outlook spreadsheet uses. You do not have to rename keys to “Given Name”. These JSON-style names auto-map:

  • Name: name, fullName, contactName, displayName → full name; firstName / first / givenName; lastName / last / familyName / surname; middleName
  • Phone: phone, phoneNumber, tel, telephone, number → main phone; mobile / cell / mobilePhone; workPhone / businessPhone / officePhone; homePhone
  • Email: email, mail, emailAddress
  • Org / title: company, org, organization, organisation, employer; title, jobTitle, position, role
  • Address: address / fullAddress / location (one-column); street, city, state, zip / zipCode / postalCode, country
  • Other: website / url / homepage; notes / note / comment / description

Anything else — id, tags, owner, a CRM field named account_owner — is left unmapped. Assign it in Step 2 or set the column to Ignore. Full name, company and the address parts are single-use: if an object has both name and fullName, the first match wins and the second is ignored. Phone and email fields are multi-use, so extra numbers keep becoming extra TEL lines.

Nested values are not flattened

A value that is itself an object or an array is JSON.stringify'd into one cell. Google People API-style records are the usual trap: phoneNumbers[0].value never becomes a phone column on its own. A field like {"phoneNumbers":[{"value":"+1 555 0101","type":"mobile"}]} lands as a single phoneNumbers cell containing the string [{"value":"+1 555 0101","type":"mobile"}]. That string does not match a HEADER_RULE, so it will not auto-map to TEL. Nested company objects, address hashes and arrays of emails behave the same way — stringify, not walk. Guessing which nested path is “the” phone is how converters mangle CRM exports; this one will not.

Flatten in your script first, then drop the flat array:

const flat = raw.map((p) => ({
  name: p.names?.[0]?.displayName ?? "",
  phone: p.phoneNumbers?.[0]?.value ?? "",
}));

Three lines is enough for the common People API / Graph shape. Keep going for email (emailAddresses[0].value) or a nested organization.name. Once the values are strings on the object, the mapper above takes over.

Phone numbers stay text

This path never goes through Excel, so it does not strip a leading +, eat a leading zero, or rewrite a long number as 1.55E+10. What is in the JSON cell is what is written on the TEL line. That is the main practical difference versus the Excel converter: a JSON string "+44 07911 123456" survives; the same value typed into a General-format spreadsheet cell often does not.

Keep phones as JSON strings, not numbers. JSON numbers cannot represent every E.164 value losslessly, and a parser that turned 15550101000 into a Number has already dropped formatting. If an API emitted numbers, stringify them in the flatten step above. International form with a country code (+1 …, +44 …) is the safest value to put in a vCard that will be imported on a phone.

Going the other way

Need JSON back from a .vcf? Use VCF to JSON — that page is being built in parallel with this one, and it reads a vCard into a JSON array of contacts. A round trip is JSON → this converter → .vcf → that converter → JSON. Both sides run in the browser; neither uploads the file.

Once you have the .vcf, import it on an iPhone via iCloud or Android / Google Contacts the same way you would a CSV-derived vCard. For the on-disk syntax (BEGIN/END, escaping, VERSION lines) see the vCard format reference. Lists that started as a spreadsheet still belong on CSV to VCF or Excel to VCF; this page is only for a JSON array of contacts.

JSON to VCF — FAQ

  • Is my JSON contact list uploaded to a server?

    No. The file is parsed with JavaScript in this tab — JSON.parse, then the same column mapper the CSV converter uses. There is no upload endpoint. After the page has loaded you can disconnect and still drop a .json, preview the cards, and download a .vcf. Nothing in the converter posts your contacts anywhere.

  • What JSON shapes does the converter accept?

    A top-level array of objects ([{…},{…}]), or an object that holds that array under contacts, then items, data, rows, people, or results — first match wins, so {"contacts":[…], "count":3} uses contacts. If none of those names is present, an object whose only key holds an array still works ({"users":[…]}). Anything else — a single contact object, a map of id→contact, two sibling arrays — is rejected with “Couldn't find a list of contacts in this JSON — expected [{...},{...}] or {"contacts":[...]}”.

  • What happens to nested objects like phoneNumbers[0].value?

    They are not flattened. A nested object or array is JSON.stringify'd into one cell, so a Google People API field phoneNumbers: [{value, type}] becomes a single string column that will not auto-map to TEL. Pull the leaf values out in a script first (three lines of .map is enough) and drop the flat array instead.

  • Which JSON keys map automatically?

    The same HEADER_RULES as CSV, after lowercasing and stripping punctuation: name / fullName / firstName / lastName / phone / mobile / workPhone / homePhone / email / company / title / address / street / city / state / zip / country / website / notes (and common aliases such as cell, org, jobTitle, postalCode, url). Anything else is left for you to assign in Step 2, or set to Ignore.

  • How large can the JSON file be?

    The same safety limits as CSV and Excel: one file up to 10 MB, and up to 50,000 contact rows (plus 200 columns and 1,000,000 table cells). Those caps stop a huge dump from freezing the tab; split a larger list into batches.

  • Which vCard version should I pick for JSON contacts?

    vCard 3.0 is the safe default — Android, iPhone, Google Contacts and Outlook all import it. Use 2.1 only for old feature phones or car head units (non-ASCII names are quoted-printable encoded). Use 4.0 when a modern app specifically asks for RFC 6350.

  • Can one contact have more than one phone number?

    Yes. Separate keys map to separate TEL lines: phone (main/cell), mobile (cell), workPhone (work) and homePhone (home). An extra email key such as email2 becomes a second EMAIL line. Do not put several numbers in one string if you want them as distinct TEL properties — use one key per number.

JSON shapes and auto-mapped keys are covered by scripts/test-json.mjs. Last checked: August 26, 2026. About this site