# How to Build an Integration Hub with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build an API integration hub in Lovable that manages encrypted third-party credentials, defines data flows between sources and destinations, logs every sync with success and error status, and exposes webhook receiver Edge Functions. Health monitoring with automatic retry logic keeps integrations running reliably.

## Before you start

- Lovable Pro account for multiple Edge Functions
- Supabase Pro plan for Vault encrypted storage (or implement manual AES encryption on Free)
- Supabase URL, service role key saved to Cloud tab → Secrets
- At least two API keys from services you want to connect (e.g. Airtable + Slack, or Shopify + a database)

## Step-by-step guide

### 1. Create the integration hub schema with Vault credentials

Set up the database schema with secure credential storage. This is the most important step — credentials must never be stored in plaintext in application tables.

```
Build an integration hub app. Create these Supabase tables:

- connectors: id, name, logo_url, base_url, auth_type (api_key|oauth2|basic|bearer), config_schema (jsonb, describes required fields for this connector type), is_builtin (bool), created_at
- credentials: id, user_id, connector_id (FK connectors), name (user label like 'My Shopify Store'), vault_secret_id (uuid, references vault.secrets), status (active|invalid|expired), last_validated_at, created_at
- integrations: id, user_id, name, source_credential_id (FK credentials), destination_credential_id (FK credentials), field_mapping (jsonb), filter_config (jsonb, optional data filtering), schedule (cron string e.g. '0 * * * *'), is_active (bool default true), last_run_at, next_run_at, created_at
- sync_logs: id, integration_id (FK integrations), status (running|success|error|retrying), records_processed (int), error_message (text), started_at, completed_at, retry_count (int default 0), next_retry_at

For credential creation: use the Supabase Vault SQL function select vault.create_secret(secret_value, name) to store the credential. Insert the returned UUID as vault_secret_id in the credentials table.

RLS: all tables require user_id = auth.uid(). sync_logs is accessible via integration_id FK chain. Never expose vault.secrets directly to the frontend.
```

> Pro tip: Ask Lovable to create 5 built-in connector definitions (Slack, Airtable, Google Sheets, Shopify, and a generic REST API) with their config_schema already filled in. These seeded connectors appear in the connector Select when users create credentials.

**Expected result:** All four tables are created. The Vault is set up on the Supabase project. The app loads with a connector gallery and a credentials management page.

### 2. Build the credential vault UI

Create the credential management page where users can add new API keys for supported connectors. Keys are never shown after being saved.

```
Build the credentials management page at src/pages/Credentials.tsx:

1. Credential Cards: show existing credentials as Cards with connector logo, credential name, status Badge (active=green, invalid=red, expired=yellow), last_validated_at, and an Edit/Delete actions menu
2. 'Add Credential' Dialog:
   - Connector Select: shows all connectors with logos
   - Credential name Input (user labels this, e.g. 'Production Shopify')
   - Dynamic credential fields: based on the selected connector's config_schema, render the appropriate Input fields (api_key, shop_url, etc.)
   - On submit: call an Edge Function add-credential that calls vault.create_secret() server-side and returns only the new credential record ID. Never send the plaintext key back to the frontend.
   - Show a success Alert: 'Credential saved securely. The key cannot be retrieved after this.'
3. 'Validate' Button per Card: calls a validate-credential Edge Function that fetches the credential from vault, makes a test API call to the connector, and updates credentials.status based on the result
4. Revoke (delete) Button: prompts with a Dialog warning that all integrations using this credential will stop. Deletes the vault secret and the credential row.
```

**Expected result:** The credentials page shows existing credentials as Cards. Adding a new Slack API key saves it to Vault via the Edge Function. The 'Validate' button tests the key and updates the status Badge.

### 3. Build the data flow execution Edge Function

Create the core Edge Function that executes a data flow: fetches from source, applies field mapping, posts to destination, and logs the result.

```
// supabase/functions/run-integration/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, apikey, content-type' }

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const supabase = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')
  const { integrationId } = await req.json()

  const { data: integration } = await supabase
    .from('integrations')
    .select('*, source_credential:credentials!source_credential_id(connector_id, vault_secret_id), destination_credential:credentials!destination_credential_id(connector_id, vault_secret_id)')
    .eq('id', integrationId)
    .single()

  if (!integration) return new Response(JSON.stringify({ error: 'Integration not found' }), { status: 404, headers: corsHeaders })

  const { data: logRow } = await supabase.from('sync_logs').insert({ integration_id: integrationId, status: 'running', started_at: new Date().toISOString() }).select().single()
  const logId = logRow?.id

  try {
    const { data: srcSecret } = await supabase.from('vault.decrypted_secrets').select('decrypted_secret').eq('id', integration.source_credential.vault_secret_id).single()
    const { data: dstSecret } = await supabase.from('vault.decrypted_secrets').select('decrypted_secret').eq('id', integration.destination_credential.vault_secret_id).single()

    const sourceData = await fetchFromSource(integration.source_credential.connector_id, srcSecret?.decrypted_secret, integration.filter_config)
    const transformed = applyFieldMapping(sourceData, integration.field_mapping)
    const count = await postToDestination(integration.destination_credential.connector_id, dstSecret?.decrypted_secret, transformed)

    await supabase.from('sync_logs').update({ status: 'success', records_processed: count, completed_at: new Date().toISOString() }).eq('id', logId)
    await supabase.from('integrations').update({ last_run_at: new Date().toISOString() }).eq('id', integrationId)

    return new Response(JSON.stringify({ success: true, records: count }), { headers: corsHeaders })
  } catch (err) {
    const msg = err instanceof Error ? err.message : 'Unknown error'
    await supabase.from('sync_logs').update({ status: 'error', error_message: msg, completed_at: new Date().toISOString() }).eq('id', logId)
    return new Response(JSON.stringify({ error: msg }), { status: 500, headers: corsHeaders })
  }
})

function applyFieldMapping(data: any[], mapping: Record<string, string>): any[] {
  return data.map((row) => Object.fromEntries(Object.entries(mapping).map(([dest, src]) => [dest, row[src]])))
}

async function fetchFromSource(connectorId: string, secret: string, filters: any): Promise<any[]> {
  // Implement per-connector fetch logic based on connectorId
  return []
}

async function postToDestination(connectorId: string, secret: string, data: any[]): Promise<number> {
  // Implement per-connector post logic based on connectorId
  return data.length
}
```

**Expected result:** The run-integration Edge Function deploys. Calling it with an integration ID creates a sync_log entry. The function fetches credentials from Vault without exposing plaintext keys.

### 4. Add webhook receiver and the health monitoring dashboard

Create a generic webhook receiver Edge Function that accepts inbound payloads and routes them to matching integrations, plus the health monitoring dashboard that shows integration status.

```
Build two features:

1. Webhook receiver Edge Function at supabase/functions/webhook-receiver/index.ts:
   - Accept POST requests at URL pattern /functions/v1/webhook-receiver?integration_id=UUID
   - Validate the integration_id exists and is active
   - Look up the integration's source connector and validate an optional webhook_secret header (HMAC-SHA256)
   - Insert the raw payload into a webhooks_queue table: id, integration_id, payload (jsonb), received_at, processed (bool default false)
   - Return 200 immediately so the sender doesn't time out
   - A separate pg_cron job processes webhooks_queue every minute: for each unprocessed row, call run-integration with the queued payload

2. Health monitoring dashboard at src/pages/IntegrationHealth.tsx:
   - Summary Cards at top: Total Active Integrations, Syncs Today, Error Rate (%), Average Duration
   - Integration list as a DataTable: name, source → destination, last run (relative time), status Badge (healthy|warning|error|idle), error rate last 24h, 'Run Now' Button
   - Clicking a row opens a Sheet with: last 50 sync_logs for that integration as a mini DataTable, a Recharts AreaChart showing daily sync counts and error counts over 30 days
   - 'Pause/Resume' Toggle per integration (sets is_active)
   - Error state integrations show at the top of the list with a red left border
```

> Pro tip: Add a retry handler: a pg_cron job that runs every 5 minutes and queries sync_logs WHERE status = 'error' AND retry_count < 3 AND next_retry_at <= now(). For each row, call run-integration and update retry_count and next_retry_at with exponential backoff (5min, 15min, 1hr).

**Expected result:** The webhook receiver URL is live. Sending a POST to it creates a webhooks_queue entry. The health dashboard shows integration status with colored Badges and last-run times.

## Complete code example

File: `src/hooks/useSyncLogs.ts`

```typescript
import { useQuery } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'

export interface SyncLog {
  id: string
  integration_id: string
  status: 'running' | 'success' | 'error' | 'retrying'
  records_processed: number | null
  error_message: string | null
  started_at: string
  completed_at: string | null
  retry_count: number
}

export interface IntegrationHealth {
  integrationId: string
  totalRuns: number
  successRate: number
  lastStatus: SyncLog['status'] | null
  lastRunAt: string | null
  avgDurationMs: number
}

export function useSyncLogs(integrationId: string, limit = 50) {
  return useQuery({
    queryKey: ['sync-logs', integrationId],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('sync_logs')
        .select('*')
        .eq('integration_id', integrationId)
        .order('started_at', { ascending: false })
        .limit(limit)
      if (error) throw error
      return data as SyncLog[]
    },
    refetchInterval: 30_000,
  })
}

export function useIntegrationHealth(integrationId: string): IntegrationHealth {
  const { data: logs = [] } = useSyncLogs(integrationId, 100)
  const completed = logs.filter((l) => l.status !== 'running')
  const successes = completed.filter((l) => l.status === 'success').length
  const durations = completed
    .filter((l) => l.completed_at)
    .map((l) => new Date(l.completed_at!).getTime() - new Date(l.started_at).getTime())

  return {
    integrationId,
    totalRuns: completed.length,
    successRate: completed.length > 0 ? Math.round((successes / completed.length) * 100) : 100,
    lastStatus: logs[0]?.status ?? null,
    lastRunAt: logs[0]?.started_at ?? null,
    avgDurationMs: durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0,
  }
}
```

## Common mistakes

- **Storing credentials in a regular table column** — A plaintext API key in a PostgreSQL column is visible to anyone with database access, including Supabase table views, shared database credentials, and database logs. Fix: Use Supabase Vault (pgsodium) for all third-party credentials. Call vault.create_secret() to store and get back a UUID reference. Access decrypted values only in Edge Functions via vault.decrypted_secrets — never return decrypted values to the frontend.
- **Not returning 200 immediately from webhook receivers** — If your webhook receiver takes more than 5-30 seconds (depending on the sender), the external service marks the delivery as failed and may retry, causing duplicate processing. Fix: Return 200 immediately after validating the payload and inserting it into webhooks_queue. Process the queue asynchronously via pg_cron. This pattern decouples receipt from processing and prevents timeouts.
- **Logging credential values in sync_log error messages** — When a connector call fails, error messages from HTTP responses sometimes echo back request headers or authentication values. If you store the raw error in sync_logs.error_message, credentials can leak into your logs. Fix: Sanitize error messages before storing them. Strip any Authorization headers, Bearer tokens, or api_key query parameters from error strings using a regex: message.replace(/Bearer [a-zA-Z0-9._-]+/g, 'Bearer [REDACTED]').
- **Running all integrations without rate limiting** — If 20 integrations are scheduled to run at the same time (e.g., all set to cron 0 * * * *), they all fire simultaneously and may overwhelm both your Edge Function concurrency limits and the target APIs' rate limits. Fix: Spread integration schedules. When a user creates an integration with an hourly schedule, automatically offset it by a few minutes based on the integration ID hash. Also add connector-level rate limiting: maximum N calls per minute per credential.

## Best practices

- Always start each integration run by inserting a sync_log row with status 'running'. If the Edge Function crashes or times out, you can detect stale 'running' entries and mark them as errors with a cleanup job.
- Store field mappings as a declarative JSON object, not as imperative code. This makes mappings auditable, editable through a UI, and portable. Never store JavaScript eval-able expressions in field_mapping.
- Implement idempotency for integration runs. When processing data, track processed record IDs in a deduplication table so re-running an integration (due to retry) doesn't create duplicate destination records.
- Test each connector with a dry_run mode: the Edge Function fetches and transforms data but does not post to the destination. Add a 'Dry Run' button in the integration dashboard that shows a preview of what would be sent.
- Add a sync_log.checksum column that stores a hash of the processed data. On the next run, compare checksums — if nothing changed, skip the write to the destination. This prevents unnecessary API calls for unchanged data.
- Monitor Edge Function execution time. If a sync regularly takes more than 25 seconds (Supabase's Edge Function timeout is 150 seconds but calls can be cut off by load balancers), split the integration into paginated batches and chain calls.

## Frequently asked questions

### Is Supabase Vault available on the Free plan?

Supabase Vault (pgsodium encryption) is available on all plans including Free. However, the vault.secrets table and vault.decrypted_secrets view are created by default in Supabase projects. You can use vault.create_secret() on the Free plan. The Supabase Pro plan adds additional security features and dedicated infrastructure, but Vault encryption itself is not a paid feature.

### How do I add a new connector type for an API I use?

Add a row to the connectors table with the new connector's name, config_schema (the fields needed, like api_key or base_url), and auth_type. Then add a case to the fetchFromSource and postToDestination functions in the run-integration Edge Function. For complex connectors, create a separate dedicated Edge Function and call it from the main runner. The connector definition in the table drives the credential form UI automatically.

### What is the maximum data volume an integration can handle per run?

Supabase Edge Functions have a 150-second execution timeout and a 6MB response size limit. For large data sets, implement pagination in the fetchFromSource function: fetch in pages of 100-500 records and process incrementally. Store the last processed cursor (page token, ID, or timestamp) in the integrations table as last_sync_cursor so each run starts from where the previous run ended.

### Can multiple users share the same connector credential?

No — credentials are scoped to user_id by RLS. Each user must add their own credentials for each connector. This is intentional: sharing credentials would mean multiple users have access to the same API account, which is a security risk. For team use cases, you'd need to add an organization layer and update RLS to allow organization-level credential sharing with access control.

### How do I test a webhook integration locally before deploying?

Lovable does not provide a local development environment. To test webhook receivers, publish your app first (or create a staging deployment) and configure the external service to send webhooks to your deployed Edge Function URL. Use the webhooks_queue table as a debug log — inspect received payloads in the Supabase table viewer to verify the webhook is being received and stored correctly before processing.

### What happens if the destination API is down when an integration runs?

The run-integration Edge Function catches the HTTP error from the destination API, updates the sync_log with status 'error' and the error message, and returns a 500 response. The retry handler (described in the Step 4 pro tip) will automatically retry up to 3 times with exponential backoff. After 3 failures, the integration stays in error state and the health dashboard shows it with a red status Badge.

### Can I build a fully custom connector for an internal API?

Yes. Add a row to the connectors table with is_builtin = false and set base_url to your internal API's URL. Configure config_schema to define the fields users need to enter (like an API key or auth token). In the fetchFromSource function, add a case for this connector ID that uses the stored base_url and credential to make authenticated requests to your internal API. Internal APIs work as long as they're reachable from Supabase's edge network.

### How do I get help building integrations for specific APIs my business uses?

RapidDev builds production Lovable apps including custom integration hubs with connectors for specific business tools, OAuth flows, and enterprise data pipelines. Reach out if your use case requires connectors or data transformation beyond what this guide covers.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/integration-hub
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/integration-hub
