> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dispoiq.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Import contacts from a CSV

> Bring your contact list into DispoIQ, map the columns, and organize the results in a new group.

export const CsvExampleDownload = () => {
  const download = () => {
    const sample = "First name,Last name,Phone,Email,Entity name,Buyer type,Investment property address,Property city,Property county,Property state,Property zip\r\nAlex,Rivera,2025550101,alex@example.com,Example Homes LLC,Flip,10 Sample Avenue,Austin,Travis,TX,78701\r\nJordan,Lee,+12025550102,jordan@example.com,Example Rentals LLC,Rental,20 Sample Street,Austin,Travis,TX,78702\r\n";
    const url = URL.createObjectURL(new Blob([sample], {
      type: 'text/csv;charset=utf-8'
    }));
    const link = document.createElement('a');
    link.href = url;
    link.download = 'dispoiq-contacts-example.csv';
    document.body.appendChild(link);
    link.click();
    link.remove();
    window.setTimeout(() => URL.revokeObjectURL(url), 1000);
  };
  return <button type="button" onClick={download} className="not-prose" style={{
    border: '1px solid #c8d8ef',
    borderRadius: 8,
    background: '#edf4ff',
    color: '#0a52d6',
    padding: '10px 15px',
    font: 'inherit',
    cursor: 'pointer'
  }}>Download a sample CSV</button>;
};

export const WorkflowDemo = ({workflow = 'map-search', clip = 'map-address', caption = ''}) => {
  const labels = {
    'map-address': 'Select a property address',
    'map-filters': 'Adjust the search radius',
    'map-result': 'Open a property result',
    'deal-details': 'Enter the property address',
    'deal-publish': 'Save and publish a prepared deal',
    'deal-buyer-copy': 'Review the listing content',
    'import-upload': 'Choose a CSV file',
    'import-map': 'Map the phone column',
    'import-start': 'Start the contact import',
    'import-report': 'Review the import report'
  };
  const title = labels[clip] || 'DispoIQ demonstration';
  const host = useRef(null);
  const viewport = useRef(null);
  const frame = useRef(null);
  const inView = useRef(false);
  const active = useRef(false);
  const wanted = useRef(false);
  const [visible, setVisible] = useState(false);
  const [motion, setMotion] = useState(null);
  const [paused, setPaused] = useState(false);
  const [ready, setReady] = useState(false);
  const [html, setHtml] = useState('');
  const [error, setError] = useState('');
  const [attempt, setAttempt] = useState(0);
  const [dimensions, setDimensions] = useState({
    width: 1320,
    height: 748
  });
  const [width, setWidth] = useState(720);
  const scale = Math.min(1, Math.max(1, width) / dimensions.width);
  const send = useCallback((command, extra = {}) => {
    frame.current?.contentWindow?.postMessage({
      source: 'dispoiq-docs-player',
      version: 1,
      demo: workflow,
      clip,
      command,
      ...extra
    }, window.location.origin);
  }, [workflow, clip]);
  useEffect(() => {
    const preference = window.matchMedia('(prefers-reduced-motion: reduce)');
    const change = () => {
      setMotion(preference.matches);
      setPaused(preference.matches);
    };
    change();
    preference.addEventListener('change', change);
    const observer = new IntersectionObserver(([entry]) => {
      inView.current = Boolean(entry?.isIntersecting);
      active.current = inView.current && !document.hidden;
      setVisible(inView.current);
      send('activity', {
        active: active.current
      });
      send(active.current && wanted.current ? 'play' : 'pause');
    }, {
      threshold: 0.15
    });
    if (host.current) observer.observe(host.current);
    const resize = new ResizeObserver(([entry]) => {
      if (entry) setWidth(entry.contentRect.width);
    });
    if (viewport.current) resize.observe(viewport.current);
    const visibility = () => {
      active.current = inView.current && !document.hidden;
      send('activity', {
        active: active.current
      });
      send(active.current && wanted.current ? 'play' : 'pause');
    };
    document.addEventListener('visibilitychange', visibility);
    return () => {
      observer.disconnect();
      resize.disconnect();
      preference.removeEventListener('change', change);
      document.removeEventListener('visibilitychange', visibility);
    };
  }, [send]);
  useEffect(() => {
    wanted.current = motion !== null && !paused;
    send('activity', {
      active: active.current
    });
    send(wanted.current && active.current ? 'play' : 'pause');
  }, [paused, motion, ready, send]);
  useEffect(() => {
    const receive = event => {
      const data = event.data;
      if (event.source !== frame.current?.contentWindow || event.origin !== window.location.origin) return;
      if (!data || data.source !== 'dispoiq-docs-demo' || data.version !== 1 || data.demo !== workflow || data.clip !== clip) return;
      if (data.type === 'error') {
        console.error('DispoIQ animation could not start:', String(data.message || 'Native scene error').replace(/(?:pk|sk)\.[A-Za-z0-9._-]+/g, '[token]').slice(0, 500));
        setError('This animation is unavailable. Follow the written instructions.');
        send('pause');
        return;
      }
      if (data.type !== 'ready' && data.type !== 'progress') return;
      if (data.type === 'ready') {
        setReady(true);
        setError('');
        send('activity', {
          active: active.current
        });
        if (wanted.current && active.current) send('play');
      }
    };
    window.addEventListener('message', receive);
    return () => window.removeEventListener('message', receive);
  }, [send, workflow, clip]);
  useEffect(() => {
    if (!visible || html || error || motion === null) return;
    let cancelled = false;
    const load = async () => {
      try {
        if (!window.__dispoiqInlineDemoPayload) {
          const promise = (async () => {
            const abort = new AbortController();
            const timeout = window.setTimeout(() => abort.abort(), 20000);
            try {
              const manifestResponse = await fetch('/demos/native/manifest.json', {
                signal: abort.signal,
                cache: 'no-cache'
              });
              if (!manifestResponse.ok) throw new Error('Manifest unavailable');
              const manifest = await manifestResponse.json();
              if (manifest.version !== 1 || manifest.payload !== 'runtime.json') throw new Error('Invalid manifest');
              const response = await fetch('/demos/native/runtime.json', {
                signal: abort.signal,
                cache: 'no-cache'
              });
              if (!response.ok) throw new Error('Runtime unavailable');
              const payload = await response.json();
              if (payload.version !== 1 || typeof payload.script !== 'string' || typeof payload.style !== 'string' || payload.script.length > 15000000 || payload.style.length > 2000000) throw new Error('Invalid runtime');
              return {
                manifest,
                payload
              };
            } finally {
              window.clearTimeout(timeout);
            }
          })();
          window.__dispoiqInlineDemoPayload = promise;
          promise.catch(() => {
            if (window.__dispoiqInlineDemoPayload === promise) delete window.__dispoiqInlineDemoPayload;
          });
        }
        const {manifest, payload} = await window.__dispoiqInlineDemoPayload;
        const definition = manifest.clips?.[clip];
        if (!definition || definition.workflow !== workflow || !labels[clip]) throw new Error('Invalid clip');
        const sceneWidth = Number(definition.width);
        const sceneHeight = Number(definition.height);
        if (!Number.isFinite(sceneWidth) || !Number.isFinite(sceneHeight) || sceneWidth < 320 || sceneWidth > 1600 || sceneHeight < 200 || sceneHeight > 1200) throw new Error('Invalid dimensions');
        if (cancelled) return;
        setDimensions({
          width: sceneWidth,
          height: sceneHeight
        });
        const origin = window.location.origin.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
        const script = payload.script.replace(/<\/script/gi, '<\\/script');
        const stylesheet = payload.style.replace(/<\/style/gi, '<\\/style');
        setHtml(`<!doctype html><html lang="en" data-demo="${workflow}" data-clip="${clip}" data-parent-origin="${origin}"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><base href="${origin}/demos/native/"/><style>${stylesheet}</style><style>html,body{margin:0;width:${sceneWidth}px;height:${sceneHeight}px;overflow:hidden}body{background:#fff}</style></head><body><div id="demo-root"></div><script type="module">${script}</script></body></html>`);
      } catch (failure) {
        if (!cancelled) console.error('DispoIQ animation could not load:', failure instanceof Error ? failure.message : 'Animation loading failed');
        if (!cancelled) setError('This animation is unavailable. Follow the written instructions.');
      }
    };
    load();
    return () => {
      cancelled = true;
    };
  }, [visible, motion, workflow, clip, html, error, attempt]);
  useEffect(() => {
    if (!html || ready || error) return;
    const timeout = window.setTimeout(() => {
      setError('This animation could not start. Follow the written instructions.');
      send('pause');
    }, 30000);
    return () => window.clearTimeout(timeout);
  }, [html, ready, error, send]);
  const retry = () => {
    setHtml('');
    setReady(false);
    setError('');
    setAttempt(value => value + 1);
  };
  return <figure ref={host} className="not-prose" aria-label={`${title} animation`} style={{
    margin: '20px 0 26px',
    maxWidth: '100%',
    minWidth: 0
  }}>
      <div ref={viewport} style={{
    width: '100%',
    aspectRatio: `${dimensions.width}/${dimensions.height}`,
    position: 'relative',
    overflow: 'hidden',
    borderRadius: 12,
    border: '1px solid #dbe4ef',
    background: '#f4f7fc',
    boxSizing: 'border-box'
  }}>
        {html && !error ? <iframe key={`${clip}-${attempt}`} ref={frame} title={`${title} using sample data`} srcDoc={html} sandbox="allow-scripts allow-same-origin" tabIndex={-1} aria-hidden="true" style={{
    display: 'block',
    border: 0,
    width: dimensions.width,
    height: dimensions.height,
    transform: `scale(${scale})`,
    transformOrigin: 'top left',
    pointerEvents: 'none',
    opacity: ready ? 1 : 0
  }} /> : <div role={error ? 'status' : undefined} style={{
    position: 'absolute',
    inset: 0,
    display: 'grid',
    placeContent: 'center',
    padding: 24,
    color: '#52657e',
    textAlign: 'center',
    fontSize: 14
  }}><span>{error || `Loading ${title.toLowerCase()}…`}</span>{error && <button type="button" onClick={retry} style={{
    marginTop: 12,
    padding: '8px 14px',
    background: '#fff',
    border: '1px solid #c9d7ea',
    borderRadius: 8,
    cursor: 'pointer'
  }}>Retry animation</button>}</div>}
        {html && !ready && !error && <div aria-live="polite" style={{
    position: 'absolute',
    inset: 0,
    display: 'grid',
    placeContent: 'center',
    padding: 24,
    color: '#52657e',
    fontSize: 14
  }}>Loading animation…</div>}
        {ready && !error && <button type="button" aria-label={`${paused ? 'Play' : 'Pause'} ${title.toLowerCase()} animation`} title={paused ? 'Play animation' : 'Pause animation'} aria-pressed={paused} onClick={() => setPaused(value => !value)} style={{
    position: 'absolute',
    left: 10,
    top: 10,
    width: 36,
    height: 36,
    display: 'grid',
    placeItems: 'center',
    border: '1px solid #d5deeb',
    borderRadius: 8,
    background: 'rgba(255,255,255,0.95)',
    color: '#18314f',
    cursor: 'pointer',
    zIndex: 2
  }}><span aria-hidden="true">{paused ? '▶' : 'Ⅱ'}</span></button>}
      </div>
      <figcaption style={{
    marginTop: 8,
    fontSize: 12,
    lineHeight: 1.6,
    color: '#64748b'
  }}>{caption || title}.{motion && paused && <span> Motion is paused; choose Play to watch.</span>}</figcaption>
    </figure>;
};

Upload your contact list as a CSV, match its columns to the contact fields, and give the import a group name. DispoIQ processes the file in the background and records what happened to each row.

## Before you start

* You need an **Owner** or **Admin** role to start an import or save a column mapping.
* Save the file as `.csv`, with one header row and a different, nonempty name for each column.
* Include a phone column, even if some rows don't have a number yet. You must map exactly one column to **Phone**.
* Use 10-digit US phone numbers, optionally with a leading `1` or `+1`. Formatting such as parentheses and hyphens is accepted. Keep extensions out of the phone value.
* Keep the file to 50,000 contact rows or fewer.

Rows with an empty phone value are held in the import report for possible skip tracing. They don't become new contacts during the initial import. Importing a file does not start skip tracing or send a campaign.

## Upload and map your file

<Steps>
  <Step title="Open the import wizard">
    Open **Contacts** and select **Upload file**.

    On **Upload file**, choose your **CSV file** by dropping it into the upload area or browsing for it. If you've imported the same kind of export before, you can select **Use a saved mapping**.

    Select **Continue**.

    <WorkflowDemo workflow="contact-import" clip="import-upload" />
  </Step>

  <Step title="Check each column">
    On **Map columns**, review the suggested field beside every header. The sample values help you check that names, phone numbers, and property details are going to the right place.

    Map one column to **Phone**. Set any column you don't want to bring in to **Ignore**.

    Each contact field can use only one CSV column. If you choose **Phone** for a second column, DispoIQ moves that mapping and sets the previous phone column to **Ignore**. It does the same for other destination fields.

    Select **Continue** when the mapping is ready.

    <WorkflowDemo workflow="contact-import" clip="import-map" />
  </Step>

  <Step title="Name the group and review">
    On **Review & import**, check **What this import will do**, including the file, mapped columns, and phone column. **Rows previewed** is the preview count, not necessarily the full file size.

    Enter a **New group name** that you'll recognize later, such as `Austin investor export — October`. Each import creates a new group; it doesn't select an existing group.

    To reuse the mapping, enter **Save this mapping as** and select **Save mapping**. This saves the column choices, not the contacts. Saved mappings use your CSV's header names, so review the mapping again if a later export changes them.

    Use **Back** if you need to change the file or mapping, then select **Start import**.

    <WorkflowDemo workflow="contact-import" clip="import-start" />
  </Step>

  <Step title="Check the results">
    Watch **Rows processed**, **Rows in file**, and **Status**. You can select **Close** while the import runs; processing continues in the background.

    When it finishes, select **Done**. Open **Contacts → Imports**, then select **Open** beside the file to [review its results](/contacts/import-results).

    <WorkflowDemo workflow="contact-import" clip="import-report" />
  </Step>
</Steps>

New contacts and records matched to existing contacts or buyers join the new group. Suppressed, missing-phone, and invalid-phone rows are reported separately, so the group's size may be smaller than the file's row count.

## Columns you can import

Your headers don't need to use these exact names—you choose the destination in **Map columns**.

| Destination field | What to put in it |
| - | - |
| **First name** | The person's first name. |
| **Last name** | The person's last name. |
| **Phone** | One phone number per row. This column must be mapped. |
| **Email** | The contact's email address. |
| **Entity name** | A company or entity name. |
| **Buyer type** | Your buyer-type description. |
| **Investment property address** | The street address of the contact's investment property. |
| **Property city** | That property's city. |
| **Property county** | That property's county. |
| **Property state** | That property's state. |
| **Property zip** | That property's ZIP code. |

Only **Phone** requires a mapped column. The other fields are optional. Leave unrelated export columns on **Ignore**.

## Start with an example

These are illustrative contacts. Replace the sample records with your own before importing. If a value contains a comma, keep it inside double quotes.

```csv theme={null}
First name,Last name,Phone,Email,Entity name,Buyer type,Investment property address,Property city,Property county,Property state,Property zip
Alex,Rivera,2025550101,alex@example.com,Example Homes LLC,Flip,10 Sample Avenue,Austin,Travis,TX,78701
Jordan,Lee,+12025550102,jordan@example.com,Example Rentals LLC,Rental,20 Sample Street,Austin,Travis,TX,78702
```

<CsvExampleDownload />

## If the import won't start

| What you see | What to do |
| - | - |
| **Choose a CSV file.** | Export or save your spreadsheet as `.csv`, then choose that file. |
| **This file has no header row.** | Add a header row and save the CSV again. |
| **Map exactly one column to Phone before continuing.** | Choose the phone column in **Map columns**. Individual rows may still have an empty phone value. |
| A saved mapping couldn't load | Map the file manually; you can still continue. |
| **Start import** is unavailable | Check that **New group name** isn't empty. You also need an Owner or Admin role to submit the import. |
| The import couldn't be started | Check for repeated or empty headers and the row limit. Correct the file or mapping, then try again. |
| Progress couldn't be refreshed | The saved import continues in the background. Open **Contacts → Imports** to check its report rather than starting the same file again. |

<Card title="Review your import results" icon="list-check" href="/contacts/import-results">
  Understand matches, duplicates, suppressed numbers, and rows that need correcting.
</Card>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.