Saltar al contenido principal

Repositorio de GitHub

Código fuente completo y guía de configuración
Consulta el kit de inicio completo para supabase
Un kit de inicio de suscripción mínimo construido con Next.js, Supabase y Dodo Payments. Este boilerplate te ayuda a configurar rápidamente un SaaS basado en suscripción con autenticación, pagos y webhooks.
Dodo Payments Supabase Subscription Starter

Configuración Rápida

1. Requisitos Previos

2. Autenticación y Enlace

npx supabase login
git clone https://github.com/dodopayments/cloud-functions.git
cd cloud-functions/supabase
npx supabase link --project-ref your-project-ref
Obtén tu referencia de proyecto desde el Tablero de Supabase → Configuración del Proyecto

3. Configuración de la Base de Datos

  1. Ve a tu Tablero de Supabase
  2. Abre el Editor SQL
  3. Crea una nueva consulta
  4. Copia y pega todo el contenido de schema.sql
  5. Ejecuta la consulta

4. Establecer Secretos Iniciales

Supabase proporciona automáticamente SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY en tiempo de ejecución.
npx supabase secrets set DODO_PAYMENTS_API_KEY=your-api-key
Nota: Estableceremos DODO_PAYMENTS_WEBHOOK_KEY después del despliegue una vez que tengas tu URL de webhook.

5. Desplegar

La función ya está configurada en functions/webhook/index.ts - solo desplégala:
npm run deploy

6. Obtén Tu URL de Webhook

Tu URL de webhook es:
https://[project-ref].supabase.co/functions/v1/webhook

7. Registrar Webhook en el Tablero de DodoPayments

  1. Ve a DodoPayments Dashboard → Desarrollador → Webhooks
  2. Crea un nuevo endpoint de webhook
  3. Configura tu URL de webhook como el endpoint
  4. Habilita estos eventos de suscripción:
    • subscription.active
    • subscription.cancelled
    • subscription.renewed
  5. Copia el Secreto de Firma

8. Establecer Clave de Webhook y Redeploy

npx supabase secrets set DODO_PAYMENTS_WEBHOOK_KEY=your-webhook-signing-key
npm run deploy

Qué Hace

Procesa eventos de suscripción y los almacena en Supabase PostgreSQL:
  • subscription.active - Crea/actualiza registros de clientes y suscripciones
  • subscription.cancelled - Marca la suscripción como cancelada
  • subscription.renewed - Actualiza la fecha de facturación siguiente

Características Clave

Verificación de firma - Usando la biblioteca dodopayments
Idempotencia - Previene el procesamiento duplicado con IDs de webhook
Registro de eventos - Auditoría completa en la tabla webhook_events
Manejo de errores - Registrado y reintentable
Nota: Esta implementación demuestra el manejo de tres eventos de suscripción centrales (subscription.active, subscription.cancelled, subscription.renewed) con campos mínimos. Puedes extenderlo fácilmente para soportar tipos de eventos y campos adicionales según tus requisitos.

Archivos de Configuración

{
  "name": "dodo-webhook-supabase",
  "version": "1.0.0",
  "type": "module",
  "description": "DodoPayments Webhook Handler for Supabase Edge Functions",
  "scripts": {
    "dev": "npx supabase functions serve webhook --no-verify-jwt --workdir ..",
    "deploy": "npx supabase functions deploy webhook --no-verify-jwt --workdir .."
  }
}

Esquema de Base de Datos

-- DodoPayments Webhook Database Schema
-- Compatible with PostgreSQL (Supabase, Neon, etc.)

-- Enable UUID extension (if not already enabled)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Customers table
CREATE TABLE IF NOT EXISTS customers (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  email TEXT NOT NULL,
  name TEXT NOT NULL,
  dodo_customer_id TEXT UNIQUE NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Subscriptions table
CREATE TABLE IF NOT EXISTS subscriptions (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  dodo_subscription_id TEXT UNIQUE NOT NULL,
  product_id TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'on_hold', 'cancelled', 'failed', 'expired')),
  billing_interval TEXT NOT NULL CHECK (billing_interval IN ('day', 'week', 'month', 'year')),
  amount INTEGER NOT NULL,
  currency TEXT NOT NULL,
  next_billing_date TIMESTAMP WITH TIME ZONE NOT NULL,
  cancelled_at TIMESTAMP WITH TIME ZONE,
  created_at TIMESTAMP WITH TIME ZONE NOT NULL,
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Webhook events log
CREATE TABLE IF NOT EXISTS webhook_events (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  webhook_id TEXT UNIQUE,
  event_type TEXT NOT NULL,
  data JSONB NOT NULL,
  processed BOOLEAN DEFAULT FALSE,
  error_message TEXT,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  processed_at TIMESTAMP WITH TIME ZONE,
  attempts INTEGER DEFAULT 0
);

-- Indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_customers_email ON customers(email);
CREATE INDEX IF NOT EXISTS idx_customers_dodo_id ON customers(dodo_customer_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_dodo_id ON subscriptions(dodo_subscription_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed, created_at);
CREATE INDEX IF NOT EXISTS idx_webhook_events_type ON webhook_events(event_type);
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_webhook_events_webhook_id ON webhook_events(webhook_id);

-- Function to automatically update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Triggers to automatically update updated_at
CREATE TRIGGER update_customers_updated_at
  BEFORE UPDATE ON customers
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

CREATE TRIGGER update_subscriptions_updated_at
  BEFORE UPDATE ON subscriptions
  FOR EACH ROW
  EXECUTE FUNCTION update_updated_at_column();

-- Comments for documentation
COMMENT ON TABLE customers IS 'Stores customer information from DodoPayments';
COMMENT ON TABLE subscriptions IS 'Stores subscription data from DodoPayments';
COMMENT ON TABLE webhook_events IS 'Logs all incoming webhook events for audit and retry purposes';

COMMENT ON COLUMN customers.dodo_customer_id IS 'Unique customer ID from DodoPayments';
COMMENT ON COLUMN subscriptions.dodo_subscription_id IS 'Unique subscription ID from DodoPayments';
COMMENT ON COLUMN subscriptions.amount IS 'Amount in smallest currency unit (e.g., cents)';
COMMENT ON COLUMN subscriptions.currency IS 'Currency used for the subscription payments (e.g., USD, EUR, INR)';
COMMENT ON COLUMN webhook_events.attempts IS 'Number of processing attempts for failed webhooks';
COMMENT ON COLUMN webhook_events.data IS 'Full webhook payload as JSON';
Tablas creadas:
  • customers - Email, nombre, dodo_customer_id
  • subscriptions - Estado, monto, next_billing_date, vinculado a clientes
  • webhook_events - Registro de eventos con webhook_id para idempotencia

Código de Implementación

import { serve } from 'https://deno.land/[email protected]/http/server.ts';
import { createClient, SupabaseClient } from 'https://esm.sh/@supabase/[email protected]';
import { DodoPayments } from 'https://esm.sh/[email protected]';

export const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, webhook-id, webhook-signature, webhook-timestamp',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
};

interface WebhookPayload {
  business_id: string;
  type: string;
  timestamp: string;
  data: {
    payload_type: "Payment" | "Subscription" | "Refund" | "Dispute" | "LicenseKey";
    subscription_id: string;
    customer: {
      customer_id: string;
      email: string;
      name: string;
    };
    product_id: string;
    status: string;
    recurring_pre_tax_amount: number;
    payment_frequency_interval: string;
    created_at: string;
    next_billing_date: string;
    cancelled_at?: string | null;
    currency: string;
  };
}

// Handle subscription events
async function handleSubscriptionEvent(supabase: SupabaseClient, payload: WebhookPayload, status: string) {
  if (!payload.data.customer.customer_id || !payload.data.subscription_id) {
    throw new Error('Missing required fields: customer_id or subscription_id');
  }

  try {
    console.log('🔄 Processing subscription event:', JSON.stringify(payload, null, 2));
    
    const customer = payload.data.customer;
    
    // Upsert customer (create if doesn't exist, otherwise update)
    const customerResult = await supabase
      .from('customers')
      .upsert({
        email: customer.email,
        name: customer.name,
        dodo_customer_id: customer.customer_id
      }, {
        onConflict: 'dodo_customer_id',
        ignoreDuplicates: false
      })
      .select('id')
      .single();

    if (customerResult.error) {
      console.error('❌ Failed to upsert customer:', customerResult.error);
      throw new Error(`Failed to upsert customer: ${customerResult.error.message}`);
    }

    const customerId = customerResult.data.id;
    console.log(`✅ Customer upserted with ID: ${customerId}`);

    // Upsert subscription
    const subscriptionResult = await supabase
      .from('subscriptions')
      .upsert({
        customer_id: customerId,
        dodo_subscription_id: payload.data.subscription_id,
        product_id: payload.data.product_id,
        status,
        billing_interval: payload.data.payment_frequency_interval.toLowerCase(),
        amount: payload.data.recurring_pre_tax_amount,
        currency: payload.data.currency,
        created_at: payload.data.created_at,
        next_billing_date: payload.data.next_billing_date,
        cancelled_at: payload.data.cancelled_at ?? null,
        updated_at: new Date().toISOString()
      }, {
        onConflict: 'dodo_subscription_id',
        ignoreDuplicates: false
      })
      .select();

    if (subscriptionResult.error) {
      console.error('❌ Failed to upsert subscription:', subscriptionResult.error);
      throw new Error(`Failed to upsert subscription: ${subscriptionResult.error.message}`);
    }

    console.log(`✅ Subscription upserted with ${status} status`);

  } catch (error) {
    console.error('❌ Error in handleSubscriptionEvent:', error);
    console.error('❌ Raw webhook data:', JSON.stringify(payload, null, 2));
    throw error;
  }
}

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

  // Validate required environment variables
  try {
    const supabaseUrl = Deno.env.get('SUPABASE_URL');
    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
    
    if (!supabaseUrl || !supabaseServiceKey) {
      console.error('❌ Missing required environment variables');
      return new Response(
        JSON.stringify({ error: 'Server configuration error' }),
        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      );
    }

    const rawBody = await req.text();
    console.log('📨 Webhook received');

    const apiKey = Deno.env.get('DODO_PAYMENTS_API_KEY');
    const webhookKey = Deno.env.get('DODO_PAYMENTS_WEBHOOK_KEY');

    if (!apiKey) {
      console.error('❌ DODO_PAYMENTS_API_KEY is not configured');
      return new Response(
        JSON.stringify({ error: 'API key not configured' }),
        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      );
    }

    if (!webhookKey) {
      console.error('❌ DODO_PAYMENTS_WEBHOOK_KEY is not configured');
      return new Response(
        JSON.stringify({ error: 'Webhook verification key not configured' }),
        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      );
    }

    // Verify webhook signature (required for security)
    const webhookHeaders = {
      'webhook-id': req.headers.get('webhook-id') || '',
      'webhook-signature': req.headers.get('webhook-signature') || '',
      'webhook-timestamp': req.headers.get('webhook-timestamp') || '',
    };

    try {
      const dodoPaymentsClient = new DodoPayments({
        bearerToken: apiKey,
        webhookKey: webhookKey,
      });
      const unwrappedWebhook = dodoPaymentsClient.webhooks.unwrap(rawBody, { headers: webhookHeaders });
      console.log('Unwrapped webhook:', unwrappedWebhook);
      console.log('✅ Webhook signature verified');
    } catch (error) {
      console.error('❌ Webhook verification failed:', error);
      return new Response(
        JSON.stringify({ 
          error: 'Webhook verification failed',
          details: error instanceof Error ? error.message : 'Invalid signature'
        }),
        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      );
    }

    // Initialize Supabase client
    const supabase = createClient(supabaseUrl, supabaseServiceKey);

    let payload: WebhookPayload;
    try {
      payload = JSON.parse(rawBody) as WebhookPayload;
    } catch (parseError) {
      console.error('❌ Failed to parse webhook payload:', parseError);
      return new Response(
        JSON.stringify({ error: 'Invalid JSON payload' }),
        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      );
    }

    const eventType = payload.type;
    const eventData = payload.data;
    const webhookId = req.headers.get('webhook-id') || '';

    console.log(`📋 Webhook payload:`, JSON.stringify(payload, null, 2));

    // Check for duplicate webhook-id (idempotency)
    if (webhookId) {
      const { data: existingEvent } = await supabase
        .from('webhook_events')
        .select('id')
        .eq('webhook_id', webhookId)
        .single();

      if (existingEvent) {
        console.log(`⚠️ Webhook ${webhookId} already processed, skipping (idempotency)`);
        return new Response(
          JSON.stringify({ success: true, message: 'Webhook already processed' }),
          { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
        );
      }
    }

    // Log webhook event with webhook_id for idempotency
    const logResult = await supabase.from('webhook_events').insert([{
      webhook_id: webhookId || null,
      event_type: eventType,
      data: eventData,
      processed: false,
      created_at: new Date().toISOString()
    }]).select('id').single();

    if (logResult.error) {
      console.error('❌ Failed to log webhook event:', logResult.error);
      return new Response(
        JSON.stringify({ error: 'Failed to log webhook event' }),
        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      );
    }

    const loggedEventId = logResult.data.id;
    console.log('📝 Webhook event logged with ID:', loggedEventId);

    console.log(`🔄 Processing: ${eventType} (${eventData.payload_type || 'unknown payload type'})`);

    try {
      switch (eventType) {
        case 'subscription.active':
          await handleSubscriptionEvent(supabase, payload, 'active');
          break;
        case 'subscription.cancelled':
          await handleSubscriptionEvent(supabase, payload, 'cancelled');
          break;
        case 'subscription.renewed':
          console.log('🔄 Subscription renewed - keeping active status and updating billing date');
          await handleSubscriptionEvent(supabase, payload, 'active');
          break;
        default:
          console.log(`ℹ️ Event ${eventType} logged but not processed (no handler available)`);
      }
      
      const updateResult = await supabase
        .from('webhook_events')
        .update({ 
          processed: true, 
          processed_at: new Date().toISOString() 
        })
        .eq('id', loggedEventId);
      
      if (updateResult.error) {
        console.error('❌ Failed to mark webhook as processed:', updateResult.error);
      } else {
        console.log('✅ Webhook marked as processed');
      }
    } catch (processingError) {
      console.error('❌ Error processing webhook event:', processingError);
      
      await supabase
        .from('webhook_events')
        .update({ 
          processed: false,
          error_message: processingError instanceof Error ? processingError.message : 'Unknown error',
          processed_at: new Date().toISOString()
        })
        .eq('id', loggedEventId);
      
      throw processingError;
    }

    console.log('✅ Webhook processed successfully');

    return new Response(
      JSON.stringify({ 
        success: true, 
        event_type: eventType,
        event_id: loggedEventId
      }),
      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    );

  } catch (error) {
    console.error('❌ Webhook processing failed:', error);
    return new Response(
      JSON.stringify({ 
        error: 'Webhook processing failed',
        details: error instanceof Error ? error.message : 'Unknown error'
      }),
      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    );
  }
});

Cómo Funciona

La Función Edge basada en Deno:
  1. Verifica la firma - Usa la biblioteca dodopayments para la verificación HMAC-SHA256
  2. Verifica la idempotencia - Busca el ID de webhook para prevenir el procesamiento duplicado
  3. Registra el evento - Almacena los datos de webhook en bruto en la tabla webhook_events
  4. Procesa actualizaciones - Crea o actualiza clientes y suscripciones a través del cliente de Supabase
  5. Maneja errores - Registra fallos y marca el evento para reintento

Pruebas

Desarrollo local:
cd supabase
npm run dev
# Available at http://localhost:54321/functions/v1/webhook
La --no-verify-jwt bandera es requerida porque los webhooks no incluyen tokens JWT. La seguridad se proporciona mediante la verificación de la firma del webhook.
Ver logs:
npx supabase functions logs webhook
O en Tablero de Supabase → Funciones Edge → webhook → pestaña de Logs Configurar en el Tablero de DodoPayments:
  1. Ve a Desarrolladores → Webhooks
  2. Agrega un endpoint con tu URL de Supabase
  3. Habilita: subscription.active, subscription.cancelled, subscription.renewed

Problemas Comunes

ProblemaSolución
Verificación fallidaVerifica que la clave de webhook sea correcta desde el tablero de DodoPayments
Error de permiso de base de datosAsegúrate de usar la Clave de Rol de Servicio
Error de verificación de JWTDespliega con la --no-verify-jwt bandera
Función no encontradaVerifica que la referencia del proyecto sea correcta y que la función esté desplegada

Recursos