Skip to content

Backend

Pipedrive ↔ Supabase Bidirectional Sync — Webhook + Edge Function Pattern

All articles
🔄 📊 🚫

CRM + database sync is a loop trap. Deal changes trigger database updates, which fire database triggers that push back to Pipedrive, which fires webhooks again. Break the cycle with an origin field and one-way gates.

Aidxn ships Pipedrive + Supabase integrations on almost every project: Rebuild Relief, Staff Operations Dashboard, client SaaS platforms. The pattern is always the same. A deal moves in Pipedrive, we need that status change in the database so our app reflects it in real-time. A quote gets generated in the app, we push it back to Pipedrive so the sales team sees it attached to the deal. This is bi-directional sync, and it's a minefield if you don't architect it right. Without loop prevention, a deal change fires a webhook, updates Supabase, which fires a database trigger, which calls Pipedrive's API, which fires the webhook again, and your logs are now 10,000 identical events in 30 seconds. The pattern: Pipedrive webhook → Netlify function → Supabase upsert with origin tracking. Supabase trigger → Netlify function → Pipedrive API call, but only if the origin wasn't Pipedrive. This breaks the loop and makes both systems the single source of truth for their own data.

Why Bi-Directional Sync Breaks (Without Origin Gates)

The loop happens because both systems are reactive. Pipedrive fires a `deal.updated` webhook when a deal moves. Your function sees this, upserts the deal to Supabase. Supabase has a trigger: "When a deal row updates, call this edge function to sync back to Pipedrive." The edge function fires, calls Pipedrive's REST API to update the same deal, and Pipedrive fires the webhook *again*. Now you're processing the same event twice. The second time, you upsert again (might not be a problem if you're careful), but your function calls Pipedrive again, and the webhook fires a third time. This multiplies exponentially if your function is slow or fails partway through — you end up with 10k rows in your logs, timeouts, rate-limit bans from Pipedrive, and a PagerDuty incident at 2am.

The fix is an `origin` field on every table. When Pipedrive updates the deal, you insert the row with `origin: 'pipedrive'`. When the Supabase trigger fires, it checks the origin. If it's already `'pipedrive'`, the function returns early — don't call the API. If origin is `'app'` (something changed in your app), then *you* call Pipedrive. This single-direction gate prevents the loop entirely. The data flows in a diamond: Pipedrive → Supabase, App → Supabase, Supabase → Pipedrive (but only from app-originated changes). The Pipedrive → Supabase path doesn't loop back.

The second loop risk: idempotency. Pipedrive retries webhooks. If your function doesn't track which deals it has already synced, you might update the deal twice. Unlike Stripe (where duplicate charges are catastrophic), duplicate upserts are usually safe, but they waste API calls and slow your function. Log webhook IDs to prevent re-processing.

Three-Component Architecture

The full flow has three moving parts. First: Pipedrive webhooks. Second: a Netlify function that receives the webhook and upserts to Supabase. Third: a Supabase edge function that runs on database triggers and calls Pipedrive's API. All three have to agree on the origin field to prevent loops.

Pipedrive sends webhooks for deal updates. The payload includes the deal ID, status, person (contact), and custom fields. Your Netlify function reads this, verifies it came from Pipedrive (using a signing secret), and upserts to a `deals` table with `origin: 'pipedrive'`. The upsert is idempotent — same deal ID in, same row out, no duplicates. Then, if the deal status changes and you have business logic that should run (e.g., assign to a task queue, send a notification), that's in the same function or a separate async function you call. Finally, Supabase watches the `deals` table. When a row updates and origin is `'app'` (user changed something in your dashboard), a database trigger fires an edge function that calls Pipedrive's API. This writes the change back to Pipedrive, closing the loop without doubling back.

Pipedrive Webhook Setup

First, generate a webhook signing secret in Pipedrive. Go to Settings → Webhooks, click "Create webhook," pick your events (`deals.updated`, `deals.merged`, `persons.updated`), and set the URL to your Netlify function (e.g., `https://your-site.netlify.app/.netlify/functions/sync-pipedrive`). Pipedrive gives you a signing secret — store this in your `netlify.toml` environment variables or Netlify dashboard.

Your Netlify function receives the webhook request. Verify the signature first — this prevents spoofed requests. Pipedrive calculates `HMAC-SHA256(raw_body, signing_secret)` and sends it in the `X-Pipedrive-Signature` header. If the signatures match, you can trust the payload. Then, extract the deal ID, status, person, and any custom fields you care about. Upsert to Supabase with the origin field set to `'pipedrive'`. Return 200 OK to acknowledge receipt.

// netlify/functions/sync-pipedrive.ts
import { createClient } from '@supabase/supabase-js';
import crypto from 'crypto';

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_KEY!
);

const PIPEDRIVE_SIGNING_SECRET = process.env.PIPEDRIVE_SIGNING_SECRET!;

function verifyPipedriveSignature(rawBody: string, signature: string): boolean {
  const hash = crypto
    .createHmac('sha256', PIPEDRIVE_SIGNING_SECRET)
    .update(rawBody)
    .digest('hex');
  return hash === signature;
}

export default async (event: any) => {
  const rawBody = event.body;
  const signature = event.headers['x-pipedrive-signature'];

  if (!rawBody || !signature) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'Missing body or signature' })
    };
  }

  // Verify signature
  if (!verifyPipedriveSignature(rawBody, signature)) {
    console.error('Pipedrive signature verification failed');
    return {
      statusCode: 403,
      body: JSON.stringify({ error: 'Signature verification failed' })
    };
  }

  const payload = JSON.parse(rawBody);
  const { action, object, data } = payload;

  // Skip if already processed (idempotency)
  const { data: existing } = await supabase
    .from('webhook_log')
    .select('id')
    .eq('webhook_id', payload.id)
    .single();

  if (existing) {
    console.log('Webhook already processed:', payload.id);
    return { statusCode: 200, body: JSON.stringify({ received: true }) };
  }

  // Log this webhook delivery
  await supabase.from('webhook_log').insert({
    webhook_id: payload.id,
    action,
    object,
    processed_at: new Date().toISOString()
  });

  // Upsert deal if this is a deal update
  if (object === 'deal' && data) {
    const { error } = await supabase
      .from('deals')
      .upsert({
        pipedrive_deal_id: data.id,
        title: data.title,
        status: data.status,
        person_id: data.person_id?.value,
        value: data.value,
        origin: 'pipedrive', // <-- CRITICAL: mark origin
        synced_at: new Date().toISOString()
      }, {
        onConflict: 'pipedrive_deal_id'
      });

    if (error) {
      console.error('Supabase upsert failed:', error);
      return {
        statusCode: 500,
        body: JSON.stringify({ error: 'Upsert failed' })
      };
    }
  }

  return {
    statusCode: 200,
    body: JSON.stringify({ received: true })
  };
};

Supabase Trigger + Edge Function (Return Path)

Now the reverse: when a deal changes in your app, you need to push that back to Pipedrive. Create a trigger on the `deals` table that fires an edge function whenever a deal row updates. The function checks the `origin` field. If it's `'pipedrive'`, return early — don't call the API, you'll just create a loop. If it's `'app'` or `NULL` (meaning the app changed it), call Pipedrive's API to update the deal, then set `origin: 'pipedrive'` on your local row so the webhook handler doesn't double-process when Pipedrive fires the webhook back.

The SQL trigger lives in your Supabase schema. It fires on `UPDATE` of the deals table and calls an edge function with the new and old row data. The function uses the Pipedrive API token (store this in Supabase secrets, not in your code) to PATCH the deal, then updates the local row's `synced_at` timestamp so you know when it last synced.

-- Create trigger on deals table
create trigger sync_deal_to_pipedrive
after update on deals
for each row
when (
  -- Only sync if origin is 'app' (don't loop back Pipedrive-originated changes)
  new.origin = 'app' or new.origin is null
)
execute function sync_deal_to_pipedrive_fn();

The edge function, written in TypeScript and deployed to Supabase:

// supabase/functions/sync-deal-to-pipedrive/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.104.0';

const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceRole = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const pipedriveApiToken = Deno.env.get('PIPEDRIVE_API_TOKEN')!;

const supabase = createClient(supabaseUrl, supabaseServiceRole);

serve(async (req) => {
  const payload = await req.json();
  const { record } = payload; // new row data

  // Double-check: if origin is pipedrive, bail
  if (record.origin === 'pipedrive') {
    console.log('Origin is pipedrive, skipping reverse sync');
    return new Response(
      JSON.stringify({ skipped: true }),
      { status: 200 }
    );
  }

  // Call Pipedrive API to update the deal
  const pipedriveUrl = `https://api.pipedrive.com/v1/deals/${record.pipedrive_deal_id}`;

  try {
    const response = await fetch(`${pipedriveUrl}?api_token=${pipedriveApiToken}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: record.title,
        status: record.status,
        value: record.value
      })
    });

    if (!response.ok) {
      const error = await response.json();
      console.error('Pipedrive API error:', error);
      throw new Error(`Pipedrive API returned ${response.status}`);
    }

    // Update local row to mark as synced and set origin to pipedrive
    // (next webhook from PD won't loop because origin is now pipedrive)
    await supabase
      .from('deals')
      .update({
        origin: 'pipedrive',
        synced_at: new Date().toISOString()
      })
      .eq('id', record.id);

    return new Response(
      JSON.stringify({ synced: true }),
      { status: 200 }
    );
  } catch (error: any) {
    console.error('Sync to Pipedrive failed:', error.message);
    return new Response(
      JSON.stringify({ error: error.message }),
      { status: 500 }
    );
  }
});

Loop Prevention: The Origin Field

The `origin` field is your loop breaker. Every row in every synced table must have it. When Pipedrive pushes data, set `origin: 'pipedrive'`. When your app pushes data, set `origin: 'app'`. The trigger checks origin before calling Pipedrive. The webhook handler never trusts its own pushes — it always respects Pipedrive's version as authoritative.

Schematically:

-- deals table schema
create table deals (
  id uuid primary key default gen_random_uuid(),
  pipedrive_deal_id integer unique not null,
  title text not null,
  status text not null,
  value integer,
  origin text check (origin in ('app', 'pipedrive')), -- <-- gate
  synced_at timestamp,
  created_at timestamp default now(),
  updated_at timestamp default now()
);

-- Trigger only runs if origin != 'pipedrive'
-- Webhook handler always sets origin = 'pipedrive'
-- This one field prevents all loops

Idempotency & Webhook Deduplication

Pipedrive retries webhooks for 24 hours if you don't return 200. Your function must be idempotent. Log every webhook ID to a `webhook_log` table (as shown in the code above). Before processing, check if `webhook_id` already exists. If it does, return 200 immediately — don't re-process. This prevents duplicate upserts even if the same webhook fires twice.

Same pattern for edge function retries: if you call Pipedrive's API and the network times out, Supabase might retry the trigger. To protect against this, add a `last_synced_webhook_id` field to the deals table. When you sync a deal back to Pipedrive, store the webhook ID you expect to receive. When the webhook comes back and origin is pipedrive, check if this webhook ID matches `last_synced_webhook_id`. If it does, you've already processed this cycle — return early.

Six FAQs

What if Pipedrive's API is down when my edge function calls it?

Return 500 and let Supabase retry. The trigger will fire again on the next update attempt, or you can manually retry via the Supabase Functions logs. You can also queue failed syncs to a dead-letter table (`deals_sync_failed`) and periodically retry them. Don't silently fail — log the error and alert your team.

How do I handle custom fields in Pipedrive?

Pipedrive's custom fields are keyed by integer IDs. When you fetch a deal, the custom field values live in a `custom_fields` object keyed by field ID. Map these IDs to friendly column names in Supabase and sync them over. Document the mapping in your code — it's easy to forget which field ID is "Expected Close Date" vs "Lead Source."

What if a user deletes a deal in Pipedrive?

Pipedrive sends a `deal.deleted` webhook. Your function receives it and soft-deletes the row in Supabase (set `deleted_at` timestamp, don't hard-delete). This preserves audit trails and avoids breaking references. If you hard-delete and a user undoes the Pipedrive deletion, you'll have a ghost deal with no local record.

Can I sync contacts (persons) the same way?

Yes, identical pattern. Webhooks on `persons.updated`, upsert to a `contacts` table with origin tracking, Supabase trigger to sync changes back to Pipedrive. One caveat: Pipedrive's person object is immutable in some ways (you can't change the ID). Be careful not to overwrite primary keys.

How do I debug if the sync is stuck?

Check three places: Pipedrive's webhook log (Settings → Webhooks → Endpoint), your Netlify Functions logs (for errors in the sync function), and Supabase's edge function logs (Supabase dashboard → SQL Editor → Functions). If Pipedrive shows "delivered" but your function logs don't exist, the webhook is reaching Netlify but the function isn't logging. If both are silent, check your `webhook_log` table — did the webhook arrive but get deduplicated?

Should I sync every field or just the ones I need?

Sync the minimum set of fields you actually use in your app. Syncing 50 fields when you only display 5 is wasteful and increases the blast radius if something breaks. Start with deal ID, title, status, and person ID. Add fields as features demand them. Document which fields are synced and why.

The Bottom Line

Bi-directional CRM + database sync is a loop trap without an origin field. The pattern: Pipedrive webhook → Netlify function → Supabase upsert with `origin: 'pipedrive'`. Supabase trigger → Edge function → Pipedrive API, but only if `origin: 'app'`. The `origin` field is your circuit breaker — it prevents loops entirely by making the sync direction one-way from Pipedrive and checking before syncing back. Log webhook IDs for idempotency. Test the loop scenario locally before deploying: update a deal in Pipedrive, watch your logs, confirm the webhook fires once and the database updates. Update the deal from your app, watch your logs, confirm Pipedrive gets called once. If you see the same webhook fire twice, origin gate is broken. This pattern scales to contacts, tasks, activities, and any other Pipedrive object. Ready to build a sales-integrated app? Check Aidxn Design integration services or read about webhook signature verification patterns for other platforms.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.