跳转到主要内容

GitHub 仓库

完整的源代码和设置指南
查看 Supabase 的完整启动工具包
一个使用 Next.js、Supabase 和 Dodo Payments 构建的最小订阅启动工具包。这个模板帮助您快速设置一个基于订阅的 SaaS,具有身份验证、支付和 Webhook。
Dodo Payments Supabase 订阅启动工具包

快速设置

1. 先决条件

2. 认证与链接

npx supabase login
git clone https://github.com/dodopayments/cloud-functions.git
cd cloud-functions/supabase
npx supabase link --project-ref your-project-ref
Supabase 仪表板 获取您的项目引用 → 项目设置

3. 数据库设置

  1. 转到您的 Supabase 仪表板
  2. 打开 SQL 编辑器
  3. 创建一个新查询
  4. 复制并粘贴 schema.sql 的全部内容
  5. 运行查询

4. 设置初始密钥

Supabase 在运行时自动提供 SUPABASE_URLSUPABASE_SERVICE_ROLE_KEY
npx supabase secrets set DODO_PAYMENTS_API_KEY=your-api-key
注意: 部署后,我们将在您获得 Webhook URL 后设置 DODO_PAYMENTS_WEBHOOK_KEY

5. 部署

该函数已经在 functions/webhook/index.ts 中设置 - 只需部署它:
npm run deploy

6. 获取您的 Webhook URL

您的 Webhook URL 是:
https://[project-ref].supabase.co/functions/v1/webhook

7. 在 DodoPayments 仪表板中注册 Webhook

  1. 转到 DodoPayments 仪表板 → 开发者 → Webhooks
  2. 创建一个新的 Webhook 端点
  3. 将您的 Webhook URL 配置为端点
  4. 启用以下订阅事件:
    • subscription.active
    • subscription.cancelled
    • subscription.renewed
  5. 复制 签名密钥

8. 设置 Webhook 密钥并重新部署

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

它的功能

处理订阅事件并将其存储在 Supabase PostgreSQL 中:
  • subscription.active - 创建/更新客户和订阅记录
  • subscription.cancelled - 将订阅标记为已取消
  • subscription.renewed - 更新下一个账单日期

主要特性

签名验证 - 使用 dodopayments 库
幂等性 - 通过 Webhook ID 防止重复处理
事件日志 - 在 webhook_events 表中完整的审计跟踪
错误处理 - 记录并可重试
注意: 此实现演示了处理三个核心订阅事件 (subscription.active, subscription.cancelled, subscription.renewed) 的最小字段。您可以根据需要轻松扩展以支持其他事件类型和字段。

配置文件

{
  "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 .."
  }
}

数据库架构

-- 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';
创建的表:
  • customers - 电子邮件、姓名、dodo_customer_id
  • subscriptions - 状态、金额、下一个账单日期,链接到客户
  • webhook_events - 带有 webhook_id 的事件日志以实现幂等性

实现代码

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' } }
    );
  }
});

工作原理

基于 Deno 的 Edge 函数:
  1. 验证签名 - 使用 dodopayments 库进行 HMAC-SHA256 验证
  2. 检查幂等性 - 查找 Webhook ID 以防止重复处理
  3. 记录事件 - 将原始 Webhook 数据存储在 webhook_events 表中
  4. 处理更新 - 通过 Supabase 客户端创建或更新客户和订阅
  5. 处理错误 - 记录失败并标记事件以进行重试

测试

本地开发:
cd supabase
npm run dev
# Available at http://localhost:54321/functions/v1/webhook
--no-verify-jwt 标志是必需的,因为 Webhook 不包含 JWT 令牌。安全性由 Webhook 签名验证提供。
查看日志:
npx supabase functions logs webhook
或在 Supabase 仪表板 → Edge Functions → webhook → 日志选项卡中查看 在 DodoPayments 仪表板中配置:
  1. 转到开发者 → Webhooks
  2. 添加带有您的 Supabase URL 的端点
  3. 启用:subscription.active、subscription.cancelled、subscription.renewed

常见问题

问题解决方案
验证失败检查 DodoPayments 仪表板中的 Webhook 密钥是否正确
数据库权限错误确保使用服务角色密钥
JWT 验证错误使用 --no-verify-jwt 标志进行部署
找不到函数验证项目引用是否正确且函数已部署

资源