Skip to main content

Customer Data Synchronization

Learn how to automatically sync customer data between Stripe and Supabase, keeping your database up-to-date with payment and subscription information.

How It Works

Tether uses Stripe webhooks to listen for customer events and automatically updates your Supabase database. This ensures your customer data is always in sync without manual intervention.

┌─────────────┐
│   Stripe    │
│   Event     │
└──────┬──────┘
       │
       ▼
┌──────────────────┐
│ Stripe Webhook   │
│    Endpoint      │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐      ┌─────────────┐
│  Process Event   │─────>│  Supabase   │
│  & Transform     │      │  Database   │
└──────────────────┘      └─────────────┘

Webhook Handler Implementation

Create an API route to handle Stripe webhooks at src/app/api/webhooks/stripe/route.ts:

1import { NextRequest, NextResponse } from 'next/server';
2import Stripe from 'stripe';
3import { supabase } from '@/lib/supabase';
4
5const stripe = new Stripe(process.env.STRIPE_TETHER_SECRET_KEY!, {
6  apiVersion: '2024-11-20.acacia',
7});
8
9const webhookSecret = process.env.STRIPE_TETHER_WEBHOOK_SECRET!;
10
11export async function POST(req: NextRequest) {
12  const body = await req.text();
13  const signature = req.headers.get('stripe-signature')!;
14
15  let event: Stripe.Event;
16
17  try {
18    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
19  } catch (err) {
20    console.error('Webhook signature verification failed:', err);
21    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
22  }
23
24  // Handle the event
25  switch (event.type) {
26    case 'customer.created':
27    case 'customer.updated':
28      await handleCustomerUpdate(event.data.object as Stripe.Customer);
29      break;
30
31    case 'customer.deleted':
32      await handleCustomerDeletion(event.data.object as Stripe.Customer);
33      break;
34
35    case 'payment_intent.succeeded':
36      await handlePaymentSuccess(event.data.object as Stripe.PaymentIntent);
37      break;
38
39    case 'customer.subscription.created':
40    case 'customer.subscription.updated':
41      await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
42      break;
43
44    case 'customer.subscription.deleted':
45      await handleSubscriptionDeletion(event.data.object as Stripe.Subscription);
46      break;
47
48    default:
49      console.log(`Unhandled event type: ${event.type}`);
50  }
51
52  return NextResponse.json({ received: true });
53}

Customer Sync Functions

Syncing Customer Data

1async function handleCustomerUpdate(customer: Stripe.Customer) {
2  const customerData = {
3    stripe_customer_id: customer.id,
4    email: customer.email || '',
5    name: customer.name || null,
6    phone: customer.phone || null,
7    metadata: customer.metadata || {},
8  };
9
10  // Upsert (insert or update) customer data
11  const { error } = await supabase
12    .from('end_customers')
13    .upsert(customerData, {
14      onConflict: 'stripe_customer_id',
15    });
16
17  if (error) {
18    console.error('Error syncing customer:', error);
19    throw error;
20  }
21
22  console.log(`Customer ${customer.id} synced successfully`);
23}

Handling Customer Deletion

1async function handleCustomerDeletion(customer: Stripe.Customer) {
2  const { error } = await supabase
3    .from('end_customers')
4    .delete()
5    .eq('stripe_customer_id', customer.id);
6
7  if (error) {
8    console.error('Error deleting customer:', error);
9    throw error;
10  }
11
12  console.log(`Customer ${customer.id} deleted successfully`);
13}

Payment Sync Functions

Recording Successful Payments

1async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
2  // First, get the customer from Supabase
3  const { data: customer } = await supabase
4    .from('end_customers')
5    .select('id')
6    .eq('stripe_customer_id', paymentIntent.customer as string)
7    .single();
8
9  if (!customer) {
10    console.error('Customer not found for payment');
11    return;
12  }
13
14  const paymentData = {
15    customer_id: customer.id,
16    stripe_payment_intent_id: paymentIntent.id,
17    amount: paymentIntent.amount,
18    currency: paymentIntent.currency,
19    status: paymentIntent.status,
20    description: paymentIntent.description || null,
21    metadata: paymentIntent.metadata || {},
22  };
23
24  const { error } = await supabase
25    .from('payments')
26    .upsert(paymentData, {
27      onConflict: 'stripe_payment_intent_id',
28    });
29
30  if (error) {
31    console.error('Error recording payment:', error);
32    throw error;
33  }
34
35  console.log(`Payment ${paymentIntent.id} recorded successfully`);
36}

Subscription Sync Functions

Syncing Subscription Data

1async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
2  const { data: customer } = await supabase
3    .from('end_customers')
4    .select('id')
5    .eq('stripe_customer_id', subscription.customer as string)
6    .single();
7
8  if (!customer) {
9    console.error('Customer not found for subscription');
10    return;
11  }
12
13  const subscriptionData = {
14    customer_id: customer.id,
15    stripe_subscription_id: subscription.id,
16    status: subscription.status,
17    price_id: subscription.items.data[0]?.price.id || '',
18    quantity: subscription.items.data[0]?.quantity || 1,
19    cancel_at_period_end: subscription.cancel_at_period_end,
20    current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
21    current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
22  };
23
24  const { error } = await supabase
25    .from('subscriptions')
26    .upsert(subscriptionData, {
27      onConflict: 'stripe_subscription_id',
28    });
29
30  if (error) {
31    console.error('Error syncing subscription:', error);
32    throw error;
33  }
34
35  console.log(`Subscription ${subscription.id} synced successfully`);
36}

Handling Initial Data Import

If you already have customers in Stripe, import them to Supabase:

1import { stripe } from '@/lib/stripe';
2import { supabase } from '@/lib/supabase';
3
4async function importExistingCustomers() {
5  let hasMore = true;
6  let startingAfter: string | undefined;
7
8  while (hasMore) {
9    const customers = await stripe.customers.list({
10      limit: 100,
11      starting_after: startingAfter,
12    });
13
14    for (const customer of customers.data) {
15      await handleCustomerUpdate(customer);
16    }
17
18    hasMore = customers.has_more;
19    if (hasMore) {
20      startingAfter = customers.data[customers.data.length - 1].id;
21    }
22  }
23
24  console.log('Customer import completed');
25}
26
27// Run this once to import existing data
28importExistingCustomers();

Linking Users to Customers

Connect Supabase Auth users with Stripe customers:

1async function linkUserToCustomer(
2  userId: string,
3  email: string
4): Promise<string> {
5  // Create or get Stripe customer
6  let stripeCustomer: Stripe.Customer;
7  
8  const existingCustomers = await stripe.customers.list({
9    email,
10    limit: 1,
11  });
12
13  if (existingCustomers.data.length > 0) {
14    stripeCustomer = existingCustomers.data[0];
15  } else {
16    stripeCustomer = await stripe.customers.create({
17      email,
18      metadata: {
19        supabase_user_id: userId,
20      },
21    });
22  }
23
24  // Sync to Supabase
25  await handleCustomerUpdate(stripeCustomer);
26
27  // Link user to customer
28  const { data: customer } = await supabase
29    .from('end_customers')
30    .select('id')
31    .eq('stripe_customer_id', stripeCustomer.id)
32    .single();
33
34  if (customer) {
35    await supabase
36      .from('users')
37      .upsert({
38        id: userId,
39        customer_id: customer.id,
40      });
41  }
42
43  return stripeCustomer.id;
44}

Monitoring and Debugging

Webhook Logging

Add logging to track webhook processing:

1// Create a webhook_logs table
2CREATE TABLE webhook_logs (
3  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
4  event_type TEXT NOT NULL,
5  event_id TEXT NOT NULL,
6  status TEXT NOT NULL,
7  error_message TEXT,
8  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
9);
10
11// Log webhook events
12async function logWebhookEvent(
13  eventType: string,
14  eventId: string,
15  status: 'success' | 'error',
16  errorMessage?: string
17) {
18  await supabase.from('webhook_logs').insert({
19    event_type: eventType,
20    event_id: eventId,
21    status,
22    error_message: errorMessage || null,
23  });
24}

Best Practices

  • Idempotency: Use upsert operations to handle duplicate webhook deliveries
  • Error Handling: Log errors but don't fail silently; return appropriate HTTP status codes
  • Async Processing: For complex operations, queue them for background processing
  • Data Validation: Validate webhook data before storing in your database
  • Monitoring: Set up alerts for failed webhook deliveries

✅ Testing Checklist

  • ✓ Create a test customer and verify it appears in Supabase
  • ✓ Update customer details in Stripe and check the sync
  • ✓ Process a test payment and confirm it's recorded
  • ✓ Create a subscription and verify the data
  • ✓ Cancel a subscription and check the status update

Next Steps