# How to Add an One-Click Contact Importer to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A contact importer lets users find friends already on your platform from their Google Contacts or phone address book. The build has four non-obvious requirements: OAuth scope `contacts.readonly` via a Supabase Edge Function, SHA-256 hashing of every phone/email before storage (never plaintext), match against pre-hashed user profiles, and GDPR-compliant 24-hour auto-deletion. With Lovable or v0 the implementation takes 8–14 hours and $0–$50/month depending on invite SMS volume.

## What a One-Click Contact Importer Actually Is

A one-click contact importer lets users trigger a single button to import their Google Contacts (web) or device address book (mobile), instantly see which of their contacts are already on the platform, and invite the rest via email or SMS. The feature has two distinctly different implementations depending on platform: web apps use the Google People API with OAuth 2.0; mobile apps use the device's native contacts API via `flutter_contacts` or a similar package. Both paths share a critical privacy architecture: contacts must be SHA-256 hashed before touching the server, matched against pre-hashed profile fields, and deleted from the database within 24 hours of import. The consent modal and GDPR delete option are not optional polish — they are regulatory requirements in most jurisdictions.

## Anatomy of the Feature

Seven components — AI tools handle the UI and Edge Function scaffolding reasonably well. The hashing layer, GDPR expiry cron, and Google OAuth redirect URI registration are where builds consistently fail without explicit instruction.

- **Google People API connector** (service): A Supabase Edge Function handles the Google OAuth 2.0 code exchange: it receives the authorization code from the client, exchanges it with Google's token endpoint for an access token, then calls `https://people.googleapis.com/v1/people/me/connections?personFields=emailAddresses,phoneNumbers` to retrieve the contact list.
- **Native contacts access (mobile)** (service): On Flutter/FlutterFlow, the `flutter_contacts` package reads device contacts as a list of `{ name, phones: [], emails: [] }` objects after `permission_handler` confirms the contacts permission is granted. On iOS, a usage description string in Info.plist is required or the App Store rejects the build.
- **Contact hashing layer** (backend): A Supabase Edge Function receives the raw contacts list from the client or Google People API response and hashes every email and phone number using SHA-256 via the Web Crypto API (`crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))`). Only the hex-encoded hashes are passed to the DB — raw emails and phone numbers never touch Supabase storage.
- **Match display component** (ui): A shadcn/ui Avatar grid comparing the hashed imported contacts against the `hashed_email` and `hashed_phone` columns in `profiles`. Matched users are displayed with their avatar and name plus an 'Add Friend' or 'Follow' button. Unmatched contacts render as 'Invite [Name]' rows with one-tap email/SMS options.
- **Invite sender** (service): A Supabase Edge Function calls Resend for email invites or Twilio's Messages API for SMS invites. Each invite message includes a unique referral token generated with `crypto.randomUUID()` stored in an `invite_tokens` table with a UNIQUE constraint on the token column.
- **Ephemeral contact store** (data): The `contact_imports` table stores only hashed contacts as a jsonb array, with `expires_at` set to `now() + interval '24 hours'` on insert. A pg_cron job (or scheduled Supabase Edge Function) deletes expired rows daily. Users can trigger immediate deletion from their settings.
- **Permission and consent UI** (ui): A modal shown before any contact access is requested. Contains a bulleted list of exactly what is accessed (email addresses and phone numbers from Google Contacts or device), how it is stored (hashed, not readable), retention period (24 hours), and a link to the privacy policy. 'Allow' and 'Decline' buttons — decline returns the user to the previous screen with no side effects.

## Data model

This feature requires two schema additions: a `contact_imports` table for ephemeral hashed storage and `hashed_email`/`hashed_phone` columns on the existing `profiles` table. Run this in the Supabase SQL editor — pg_cron handles the 24-hour expiry automatically:

```sql
-- Enable pg_cron extension (run once per project)
create extension if not exists pg_cron;

-- Add hashed fields to user profiles for contact matching
alter table public.profiles
  add column if not exists hashed_email text,
  add column if not exists hashed_phone text;

create index if not exists profiles_hashed_email_idx
  on public.profiles (hashed_email)
  where hashed_email is not null;

create index if not exists profiles_hashed_phone_idx
  on public.profiles (hashed_phone)
  where hashed_phone is not null;

-- Ephemeral contact import storage
create table public.contact_imports (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  hashed_contacts jsonb not null default '[]',
  imported_at timestamptz not null default now(),
  expires_at timestamptz not null default (now() + interval '24 hours')
);

alter table public.contact_imports enable row level security;

-- Users can only access their own import rows
create policy "user owns import"
  on public.contact_imports for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- Invite tokens for referral tracking
create table public.invite_tokens (
  id uuid primary key default gen_random_uuid(),
  token text not null unique,
  inviter_id uuid not null references auth.users(id) on delete cascade,
  invitee_email text,
  invitee_phone text,
  created_at timestamptz not null default now(),
  accepted_at timestamptz
);

alter table public.invite_tokens enable row level security;

create policy "inviter can manage tokens"
  on public.invite_tokens for all
  using (auth.uid() = inviter_id)
  with check (auth.uid() = inviter_id);

-- pg_cron: delete expired contact imports daily at 3am UTC
select cron.schedule(
  'delete-expired-contact-imports',
  '0 3 * * *',
  $$ delete from public.contact_imports where expires_at < now() $$
);
```

The pg_cron extension is available on Supabase Pro and above. On the free tier, schedule a Supabase Edge Function via the Dashboard cron scheduler instead. The hashed_email and hashed_phone fields on profiles should be populated on user signup by hashing the email in an Edge Function trigger — this enables instant matching without exposing plaintext values.

## Build paths

### Lovable — fit 3/10, 8–12 hours

Can scaffold the consent modal, match display UI, and Edge Function for Google OAuth token exchange. Two requirements are consistently missed: SHA-256 hashing of contacts before DB writes, and the 24-hour GDPR expiry. These must be explicitly specified in the prompt.

1. Create a new Lovable project with Lovable Cloud connected and Supabase auth provisioned
2. Add GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to Lovable Cloud Secrets (Cloud tab → Secrets) — these are the credentials from Google Cloud Console OAuth 2.0
3. Paste the prompt below in Agent Mode; verify in the generated Edge Function code that hashing occurs before any DB write
4. Register the published Lovable URL (not the preview URL) in Google Cloud Console under OAuth 2.0 Credentials → Authorized Redirect URIs
5. Publish and test the full OAuth flow on the published URL — do not test in the Lovable preview, which will always fail with redirect_uri_mismatch

Starter prompt:

```
Build a one-click contact importer feature. Step 1: Show a consent modal before any contact access. The modal must list: what is imported (email and phone from Google Contacts), how it is stored (SHA-256 hashed, not readable), and that data is auto-deleted after 24 hours. Add Allow and Decline buttons — Decline returns the user to the previous screen with no side effects. Step 2: On Allow, trigger Google OAuth using the contacts.readonly scope. Create a Supabase Edge Function called 'import-contacts' that: receives an OAuth authorization code, exchanges it with Google token endpoint using GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from Supabase Secrets, calls Google People API people/me/connections with personFields=emailAddresses,phoneNumbers, hashes every email and phone number using SHA-256 via crypto.subtle.digest before any DB interaction — never store plaintext contacts, normalises emails to lowercase and phone numbers to digits-only with country code prefix before hashing. Step 3: Insert a row into contact_imports table (user_id, hashed_contacts jsonb array, expires_at = now() + 24 hours). Step 4: Query profiles table for rows where hashed_email OR hashed_phone matches any hash in the imported list — run this comparison server-side in the Edge Function. Return matched user profiles (id, display_name, avatar_url) and unmatched contact names. Step 5: Display matched users in an Avatar grid with Add Friend button; display unmatched as Invite rows. Step 6: For Invite, call a second Edge Function that sends email via Resend with a referral token generated using crypto.randomUUID(). Store token in invite_tokens table with UNIQUE constraint. Step 7: Add a Delete My Contacts button in user settings that immediately deletes the contact_imports row for the current user. Google OAuth redirect_uri must use the published Lovable URL — mention this in a UI banner.
```

Limitations:

- Google OAuth redirect_uri must be the published Lovable URL — the preview URL changes each session and always triggers 'redirect_uri_mismatch'; you cannot test this feature in the Lovable preview
- AI frequently generates the hashing step incorrectly (hashing after DB write instead of before, or omitting normalisation) — inspect the Edge Function code carefully before publishing
- The 24-hour GDPR expiry via pg_cron is often omitted — verify the cron job exists in the Supabase dashboard under Database → Cron Jobs after generation

### V0 — fit 3/10, 10–14 hours

Next.js API routes handle the Google OAuth code exchange and server-side SHA-256 hashing cleanly. Mobile native contacts are not accessible from a web app, so this path is web-only. Significant additional prompting is needed for GDPR compliance logic.

1. Prompt v0 with the spec below to generate the consent modal, OAuth initiation button, and results display
2. Add GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and NEXT_PUBLIC_SUPABASE_URL env vars in the v0 Vars panel and in the Vercel dashboard
3. Run the SQL schema from the af_data_model section above in the Supabase SQL editor
4. Register the Vercel production URL in Google Cloud Console OAuth Credentials → Authorized Redirect URIs
5. Deploy to Vercel and test the full OAuth flow on the live URL — do not test the OAuth redirect in v0's preview sandbox

Starter prompt:

```
Build a contact importer for a Next.js App Router project. Create four API routes: GET /api/contacts/auth — redirects to Google OAuth with contacts.readonly scope, state param, and the production NEXTAUTH_URL as redirect_uri. GET /api/contacts/callback — receives the auth code, exchanges it for a token using GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET server-side, calls Google People API connections.list, hashes every email (lowercase) and phone number (digits + country code only) with SHA-256 using Node.js crypto.createHash('sha256'), returns hashed array (never returns plaintext to client). POST /api/contacts/match — accepts hashed contacts array, queries Supabase profiles where hashed_email = ANY(hashes) OR hashed_phone = ANY(hashes), stores the hashed list in contact_imports with expires_at = now() + 24h, returns matched profiles and unmatched count. POST /api/contacts/invite — generates crypto.randomUUID() referral token, stores in invite_tokens, calls Resend to send invite email. Create a ContactImporter client component: show consent modal (what is imported, hashed storage, 24h expiry, decline path); on Allow initiate OAuth redirect; on callback show matched Avatar grid with Add Friend buttons and unmatched Invite rows. Add a Delete Contacts button that calls DELETE /api/contacts/import for the current user's row. Wire Supabase service role key server-side for DB writes.
```

Limitations:

- Mobile native contacts (device address book) are not accessible from a web app — v0 path covers Google Contacts only
- v0 does not provision Supabase — schema, RLS policies, and pg_cron must be set up manually via the SQL editor
- The v0 sandbox cannot complete the Google OAuth redirect flow — test only on the deployed Vercel URL

### Flutterflow — fit 4/10, 2–3 days

Best path for mobile apps — `flutter_contacts` and `permission_handler` provide native device contact access without needing Google OAuth. Firebase can store hashed contacts. Google People API requires a custom Dart action for the OAuth flow.

1. Add `flutter_contacts` and `permission_handler` in FlutterFlow Settings → Pub Dependencies
2. Add a custom Dart action 'ImportDeviceContacts' that: checks ContactsPermission via permission_handler, shows the system permission dialog if not yet requested, reads all contacts as a list of { name, phones, emails }, hashes each value with the `crypto` Dart package using SHA-256, sends the hashed list to a Supabase Edge Function for matching
3. In FlutterFlow Action editor, wire the 'Import Contacts' button to the custom action, then chain an API call to the Supabase match Edge Function, then navigate to the 'Results' page passing the matched/unmatched lists
4. Add a consent bottom sheet page before the button triggers the action — include the required disclosures about data access and the 24-hour retention
5. In iOS Settings → Permissions, add the NSContactsUsageDescription string: 'Used to find your friends who are already on [App Name]' — without this the App Store will reject the build

Limitations:

- Google People API OAuth (for Google Contacts on web) requires a custom Dart action with a full OAuth code exchange flow — not available in FlutterFlow's visual action editor
- GDPR 24-hour expiry logic needs a scheduled Firebase Cloud Function or Supabase scheduled Edge Function — FlutterFlow cannot schedule background jobs natively
- The contact hashing must happen in the custom Dart action before data leaves the device — verify the hashed values, not raw contacts, are sent to the Edge Function

### Custom — fit 2/10, 3–6 weeks

Required when you need multi-provider import (Google + Outlook + LinkedIn), SOC 2 Type II compliance for enterprise B2B apps, real-time contact sync as new users join, or batch matching at 10M+ imported contact scale.

1. Multi-provider import requires separate OAuth flows for Microsoft Graph API (Outlook) and LinkedIn People API — each has different rate limits, scopes, and token refresh patterns
2. SOC 2 Type II compliance requires audit logs of every contact import event, data processing agreements with Google and Twilio, and documented retention policies
3. Real-time sync (updating matches as new users join) requires a background job that re-runs the hashed match query on every new user signup — typically a Supabase database trigger calling an Edge Function
4. Batch hashing at 10M+ contacts scale requires chunked processing in a queue (e.g., Supabase Edge Function with 50K-contact batches) to avoid Edge Function timeout limits

Limitations:

- Google People API has a per-user rate limit of 180 requests per minute — contact import for users with 10K+ contacts requires pagination that adds complexity beyond typical AI tool output
- Multi-provider de-duplication (same person in Google and Outlook contacts) requires fuzzy name matching or phone normalisation logic that is difficult to prompt reliably

## Gotchas

- **Google OAuth fails in Lovable preview with redirect_uri_mismatch** — Google Cloud Console OAuth 2.0 credentials require every redirect URI to be pre-registered. Lovable's preview URL is dynamically generated per session (`https://[hash].lovable.app`) — it is never the same URL twice and can never be pre-registered. Every OAuth attempt from the preview returns a 400 'redirect_uri_mismatch' error. Fix: Register only the published Lovable URL (e.g., `https://myapp.lovable.app`) in Google Cloud Console under OAuth Credentials → Authorized Redirect URIs. Add a visible banner in the UI: 'Contact import must be tested on the published URL, not in the editor preview.' Test only after clicking Publish in Lovable.
- **Plaintext contacts stored in the database** — AI tools default to inserting the raw contact data returned by the Google People API directly into the `contact_imports` table. This stores real names, email addresses, and phone numbers of third parties who never consented to your app — a direct GDPR Article 5 violation and regulatory risk regardless of jurisdiction. Fix: The hashing step must happen inside the Edge Function before any DB write — not in the client, not after the insert. Use `const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalised))` in the Deno Edge Function, convert the ArrayBuffer to a hex string, and verify no `body`, `email`, or `phone` fields appear in the DB record.
- **Imported contacts never deleted** — AI tools often generate the `contact_imports` table without the `expires_at` column or without setting up the pg_cron deletion job. Imported contact hashes accumulate indefinitely — potentially violating GDPR Article 5(1)(e) on storage limitation, and growing the database unboundedly. Fix: Set `expires_at = now() + interval '24 hours'` on every INSERT into `contact_imports`. Add the pg_cron schedule from the af_data_model SQL above. Verify the cron job exists in Supabase Dashboard → Database → Cron Jobs. Add an immediate-delete option in user settings as a secondary safeguard.
- **Permission denied on iOS after previous denial** — On iOS, if a user previously tapped 'Don't Allow' on the contacts permission system dialog, `flutter_contacts` returns an empty array on subsequent calls without throwing an error or showing the system prompt again. The import appears to succeed with zero contacts found — no feedback to the user that permission was denied. Fix: Before calling `flutter_contacts.getContacts()`, check `await Permission.contacts.status`. If the status is `PermissionStatus.permanentlyDenied`, do not attempt import. Instead, show a modal with a deep-link to iOS Settings: `openAppSettings()` from `permission_handler`. Never silently return an empty list.
- **Invite link token collision at scale** — AI tools frequently generate referral tokens using `Math.random().toString(36).substring(2)` or similar approaches. These produce approximately 8 characters of Base-36 (2^41 possibilities), with a collision probability exceeding 1% once the table contains ~100K tokens — well within reach for any app with viral growth. Fix: Use `crypto.randomUUID()` in Deno Edge Functions or `crypto.randomUUID()` in Node.js for all referral token generation. This produces a UUIDv4 with 2^122 possible values. Add a `UNIQUE` constraint on the `token` column in `invite_tokens` so any collision (however unlikely) causes a detectable error rather than silent overwriting.

## Best practices

- Hash contacts at the Edge Function level before any database interaction — never pass raw phone numbers or emails to Supabase storage
- Normalise before hashing: lowercase emails, strip all non-digit characters from phone numbers and prepend the country code — two representations of the same number must produce the same hash
- Set `expires_at` on every contact import row and verify the pg_cron deletion job exists in the Supabase dashboard — omitting the expiry is a GDPR violation, not just a best practice
- Register only the published URL (never the preview URL) in Google Cloud Console OAuth credentials — the preview URL changes per session
- Generate referral tokens with `crypto.randomUUID()` and enforce a UNIQUE constraint on the token column — `Math.random()` tokens collide at scale
- Always check `PermissionStatus.permanentlyDenied` before calling the native contacts API on iOS — a denied permission returns empty data silently without throwing
- Run the contact match query server-side in the Edge Function — sending hashed contact lists to the client and filtering there exposes the hash list unnecessarily
- Offer an immediate 'Delete my imported contacts' option in account settings as a GDPR right-to-erasure compliance mechanism

## Frequently asked questions

### Can I import Google Contacts into my Lovable app?

Yes, via a Supabase Edge Function that handles the OAuth 2.0 code exchange. The client initiates the Google OAuth flow with the `contacts.readonly` scope, Google redirects back to your published URL with an authorization code, the Edge Function exchanges the code for a token using your GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from Supabase Secrets, then fetches the contacts list. You cannot test this in the Lovable preview — register the published URL in Google Cloud Console and test only after publishing.

### How do I let users find friends already on my app from their contacts?

Hash every imported email and phone number with SHA-256 in the Edge Function before touching the database. On signup, hash each new user's email and phone and store the hashes in the `profiles` table. During import, query `WHERE hashed_email = ANY(imported_hashes) OR hashed_phone = ANY(imported_hashes)`. Run this query server-side — never send the hash list to the frontend for client-side filtering. This way plaintext contacts never leave the user's device.

### Is storing imported contacts a GDPR concern?

Yes, significantly. Contacts are personal data of third parties who have not consented to your app. GDPR requires a lawful basis for processing (legitimate interest is narrow here), purpose limitation, and storage minimisation. The compliant approach: hash contacts before storage, never store plaintext names or numbers, set an automatic 24-hour expiry on all import records, provide an immediate delete option, and disclose the retention period to the user before they initiate the import.

### How do I send SMS invites to contacts who are not on the platform?

Use a Supabase Edge Function that calls Twilio's Messages API with the invitee's phone number and a message containing a unique referral link. Generate the referral token with `crypto.randomUUID()` (not Math.random) and store it in an `invite_tokens` table with a UNIQUE constraint. Twilio costs approximately $0.0079 per outbound US SMS. If cost is a concern, offer email-only invites via Resend (3,000 free/month) and make SMS opt-in for the sender.

### Why does Google OAuth fail in the Lovable preview?

Lovable's preview URL is dynamically generated per session and cannot be pre-registered in Google Cloud Console. Google requires every OAuth redirect URI to be explicitly whitelisted — any non-registered URI returns a 400 'redirect_uri_mismatch' error. Register your published Lovable URL (e.g., myapp.lovable.app) in Google Cloud Console under OAuth 2.0 Credentials → Authorized Redirect URIs, then test only after publishing.

### How do I access phone contacts in a FlutterFlow mobile app?

Add `flutter_contacts` and `permission_handler` as Pub dependencies in FlutterFlow Settings. Create a custom Dart action that: checks `await Permission.contacts.status`, shows the system permission dialog if not yet requested, reads contacts with `flutter_contacts.getContacts()`, hashes each phone number and email with SHA-256 using the Dart `crypto` package, then sends the hashes to a Supabase Edge Function for matching. If `PermissionStatus.permanentlyDenied`, show a modal linking to iOS Settings — never silently return empty results.

### How long should I keep imported contact data?

24 hours is the industry standard for ephemeral contact matching. The import only needs to persist long enough to run the match query and send invites — after that, retaining the data creates GDPR exposure with no product benefit. Set `expires_at = now() + interval '24 hours'` on every INSERT and run a daily pg_cron job to delete expired rows. Also offer an immediate delete option in account settings for users who want to remove their data before the 24 hours elapse.

### What permissions do I need to request from users before importing contacts?

For Google Contacts on web: the `https://www.googleapis.com/auth/contacts.readonly` OAuth scope — users will see a Google consent screen listing what the app can access. For native mobile: the device contacts permission (`NSContactsUsageDescription` on iOS, `READ_CONTACTS` on Android). In both cases, show your own consent modal before triggering the system permission request — explain what is imported, how it is stored (hashed only), and the 24-hour retention period. A clear Decline path is legally required under GDPR Article 7.

---

Source: https://www.rapidevelopers.com/app-features/one-click-contact-importer
© RapidDev — https://www.rapidevelopers.com/app-features/one-click-contact-importer
