Import speakers from a CSV

Docs API reference

Import speakers from a CSV

A complete Node script that reads a CSV and upserts every speaker in one call.

Your speaker portal, CRM or registration tool can export its speakers as a CSV. This guide turns that file into Wunderframe presenters with one short Node script — no dependencies — that you can run again whenever the list changes. Running it twice never creates duplicates.

How it works

  • Every speaker gets a ref made from the portal’s own id, like speakerportal/1234.
  • POST /presenters/import upserts up to 500 presenters by ref per call: new refs are created, known refs are updated.
  • A row that fails is reported back and never stops the others.
  • A photo_url is fetched by Wunderframe and stored in your media library as the presenter’s photo.

Set up

  1. Create an API key

    In Settings → Integrations, create a key with the presenters scope and copy it. See Authentication.

  2. Export your speakers

    Save them as speakers.csv with a header row. The script below expects these columns; rename them in toPresenter if your export differs.

  3. Install Node.js

    Node.js 18 or newer has fetch built in, which is all the script needs.

speakers.csv
id,name,title,company,email,bio,photo
1234,Ada Lovelace,Head of Research,Analytical Engines Ltd,ada@example.com,"Writes the first programs.",https://example.com/photos/ada.jpg
1235,Grace Hopper,Rear Admiral,US Navy,grace@example.com,"Invented the compiler, found the first bug.",https://example.com/photos/grace.jpg
1236,Katherine Johnson,Mathematician,NASA,,,

The script

import-speakers.mjs
// import-speakers.mjs — upsert every speaker in a CSV into Wunderframe.
// Usage: WUNDERFRAME_API_KEY=wfi.… node import-speakers.mjs speakers.csv
import { readFile } from 'node:fs/promises';

const BASE = process.env.WUNDERFRAME_URL ?? 'https://www.wunderframe.com';
const KEY = process.env.WUNDERFRAME_API_KEY;
const SOURCE = 'speakerportal'; // prefix for refs: speakerportal/1234
const BATCH = 500; // the most one import call accepts

if (!KEY) throw new Error('Set WUNDERFRAME_API_KEY to a key with the "presenters" scope.');

/** A small CSV reader: commas, quoted fields, "" inside quotes, CRLF. */
function parseCsv(text) {
  const rows = [];
  let row = [], field = '', quoted = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (quoted) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') quoted = false;
      else field += c;
    } else if (c === '"') quoted = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n' || c === '\r') {
      if (c === '\r' && text[i + 1] === '\n') i++;
      row.push(field); rows.push(row); row = []; field = '';
    } else field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const [header, ...body] = rows.filter((r) => r.some((v) => v.trim()));
  return body.map((r) => Object.fromEntries(header.map((h, i) => [h.trim(), (r[i] ?? '').trim()])));
}

/** Map one CSV row to a presenter. Empty cells are left out, so they never blank a field. */
function toPresenter(row) {
  const presenter = {
    ref: `${SOURCE}/${row.id}`,
    name: row.name,
    title: row.title,
    organization: row.company,
    email: row.email,
    bio: row.bio,
    photo_url: row.photo
  };
  return Object.fromEntries(Object.entries(presenter).filter(([, v]) => v));
}

const rows = parseCsv(await readFile(process.argv[2] ?? 'speakers.csv', 'utf8'));
const items = rows.filter((r) => r.id && r.name).map(toPresenter);
let created = 0, updated = 0, failed = 0;

for (let start = 0; start < items.length; start += BATCH) {
  const res = await fetch(`${BASE}/api/v1/presenters/import`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ items: items.slice(start, start + BATCH) })
  });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);

  created += body.created;
  updated += body.updated;
  for (const f of body.failed) {
    failed++;
    console.warn(`Row ${start + f.index + 2} (${f.ref ?? 'no ref'}): ${f.error.message}`);
  }
}

console.log(`Done: ${created} created, ${updated} updated, ${failed} failed.`);

Run it

Shell
export WUNDERFRAME_API_KEY="wfi.…"
node import-speakers.mjs speakers.csv

The script prints the rows that failed with their line number in the CSV, then a summary:

Output
Row 3 (speakerportal/1235): The image URL answered 404.
Done: 2 created, 0 updated, 1 failed.

Good to know

  • Empty cells are skipped, so a speaker without a bio in the CSV keeps the bio someone typed in the app. To clear a field on purpose, send it as an empty string.
  • Photos must be public https images (JPEG, PNG, WebP or GIF, up to 10 MB). See Upload presenter photos.
  • Removing a speaker from the CSV does not delete the presenter. Delete them with DELETE /presenters/{id} — a presenter in a session that has not finished cannot be deleted.
  • Changes only: to see what changed on the Wunderframe side since your last run, list presenters with updated_since.