Chuyển đến nội dung chính

Kho lưu trữ GitHub

Mã nguồn hoàn chỉnh và hướng dẫn thiết lập

Thiết lập nhanh

1. Yêu cầu trước khi bắt đầu

2. Cài đặt các phụ thuộc

npm install -g netlify-cli
netlify login
git clone https://github.com/dodopayments/cloud-functions.git
cd cloud-functions/netlify
npm install

3. Thiết lập cơ sở dữ liệu

  1. Đăng ký tài khoản Neon
  2. Tạo một dự án mới
  3. Mở SQL Editor
  4. Sao chép và dán nội dung từ schema.sql
  5. Chạy truy vấn
  6. Lấy chuỗi kết nối của bạn từ Neon → Chi tiết kết nối

4. Thiết lập các biến môi trường ban đầu

netlify env:set DATABASE_URL "your-neon-connection-string"
netlify env:set DODO_PAYMENTS_API_KEY "your-api-key"
Lưu ý: Chúng tôi sẽ thiết lập DODO_PAYMENTS_WEBHOOK_KEY sau khi triển khai khi bạn có URL webhook của mình.

5. Khởi tạo & Triển khai

netlify init       # Link to your site (first time only)
npm run deploy

6. Lấy URL Webhook của bạn

URL webhook của bạn là:
https://[your-project].netlify.app/.netlify/functions/webhook

7. Đăng ký Webhook trong Bảng điều khiển DodoPayments

  1. Đi đến Bảng điều khiển DodoPayments → Nhà phát triển → Webhooks
  2. Tạo một điểm cuối webhook mới
  3. Cấu hình URL webhook của bạn làm điểm cuối
  4. Kích hoạt các sự kiện đăng ký sau:
    • subscription.active
    • subscription.cancelled
    • subscription.renewed
  5. Sao chép Signing Secret

8. Thiết lập Khóa Webhook & Triển khai lại

netlify env:set DODO_PAYMENTS_WEBHOOK_KEY "your-webhook-signing-key"
npm run deploy

Chức năng của nó

Xử lý các sự kiện đăng ký và lưu trữ chúng trong PostgreSQL:
  • subscription.active - Tạo/cập nhật hồ sơ khách hàng và đăng ký
  • subscription.cancelled - Đánh dấu đăng ký là đã hủy
  • subscription.renewed - Cập nhật ngày thanh toán tiếp theo

Tính năng chính

Xác minh chữ ký - Sử dụng thư viện dodopayments
Idempotency - Ngăn chặn việc xử lý trùng lặp với các ID webhook
Ghi lại sự kiện - Lưu trữ toàn bộ lịch sử trong bảng webhook_events
Xử lý lỗi - Đã ghi lại và có thể thử lại
Lưu ý: Triển khai này minh họa việc xử lý ba sự kiện đăng ký cốt lõi (subscription.active, subscription.cancelled, subscription.renewed) với các trường tối thiểu. Bạn có thể dễ dàng mở rộng nó để hỗ trợ các loại sự kiện và trường bổ sung dựa trên yêu cầu của bạn.

Tệp cấu hình

{
  "name": "dodo-webhook-netlify",
  "version": "1.0.0",
  "type": "module",
  "description": "DodoPayments Webhook Handler for Netlify",
  "scripts": {
    "dev": "netlify dev",
    "deploy": "netlify deploy --prod"
  },
  "dependencies": {
    "@neondatabase/serverless": "^1.0.2",
    "dodopayments": "^2.4.1"
  },
  "devDependencies": {
    "@netlify/functions": "^5.0.0",
    "netlify-cli": "^23.9.1",
    "typescript": "^5.9.3"
  }
}

Sơ đồ cơ sở dữ liệu

-- 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';
Các bảng được tạo:
  • customers - Email, tên, dodo_customer_id
  • subscriptions - Trạng thái, số tiền, next_billing_date, liên kết với khách hàng
  • webhook_events - Nhật ký sự kiện với webhook_id để đảm bảo idempotency

Mã triển khai

import { Handler, HandlerEvent, HandlerContext } from '@netlify/functions';
import { neon, NeonQueryFunction } from '@neondatabase/serverless';
import { DodoPayments } from 'dodopayments';

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

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

async function handleSubscriptionEvent(sql: NeonQueryFunction<false, false>, 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');
  }

  console.log('🔄 Processing subscription event:', JSON.stringify(payload, null, 2));

  const customer = payload.data.customer;

  // Upsert customer (create if doesn't exist, otherwise use existing)
  const customerResult = await sql`
    INSERT INTO customers (email, name, dodo_customer_id, created_at)
    VALUES (${customer.email}, ${customer.name}, ${customer.customer_id}, ${new Date().toISOString()})
    ON CONFLICT (dodo_customer_id) 
    DO UPDATE SET 
      email = EXCLUDED.email,
      name = EXCLUDED.name,
      updated_at = ${new Date().toISOString()}
    RETURNING id
  `;

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

  // Upsert subscription
  await sql`
    INSERT INTO subscriptions (
      customer_id, dodo_subscription_id, product_id, status, 
      billing_interval, amount, currency, created_at, next_billing_date, cancelled_at, updated_at
    )
    VALUES (
      ${customerId}, ${payload.data.subscription_id},
      ${payload.data.product_id}, ${status},
      ${payload.data.payment_frequency_interval.toLowerCase()}, ${payload.data.recurring_pre_tax_amount},
      ${payload.data.currency}, ${payload.data.created_at}, ${payload.data.next_billing_date},
      ${payload.data.cancelled_at ?? null}, ${new Date().toISOString()}
    )
    ON CONFLICT (dodo_subscription_id) 
    DO UPDATE SET 
      customer_id = EXCLUDED.customer_id,
      product_id = EXCLUDED.product_id,
      status = EXCLUDED.status,
      billing_interval = EXCLUDED.billing_interval,
      amount = EXCLUDED.amount,
      currency = EXCLUDED.currency,
      created_at = EXCLUDED.created_at,
      next_billing_date = EXCLUDED.next_billing_date,
      cancelled_at = EXCLUDED.cancelled_at,
      updated_at = EXCLUDED.updated_at
  `;

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

export const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
  // Handle CORS preflight
  if (event.httpMethod === 'OPTIONS') {
    return {
      statusCode: 200,
      headers: corsHeaders,
      body: 'ok'
    };
  }

  if (event.httpMethod !== 'POST') {
    return {
      statusCode: 405,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
      body: JSON.stringify({ error: 'Method not allowed' })
    };
  }

  try {
    const rawBody = event.body || '';
    console.log('📨 Webhook received');

    const DATABASE_URL = process.env.DATABASE_URL;
    const API_KEY = process.env.DODO_PAYMENTS_API_KEY;
    const WEBHOOK_KEY = process.env.DODO_PAYMENTS_WEBHOOK_KEY;

    if (!DATABASE_URL) {
      console.error('❌ Missing DATABASE_URL environment variable');
      return {
        statusCode: 500,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
        body: JSON.stringify({ error: 'Server configuration error' })
      };
    }

    // Verify required environment variables
    if (!API_KEY) {
      console.error('❌ DODO_PAYMENTS_API_KEY is not configured');
      return {
        statusCode: 500,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
        body: JSON.stringify({ error: 'API key not configured' })
      };
    }

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

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

    try {
      const dodoPaymentsClient = new DodoPayments({
        bearerToken: API_KEY,
        webhookKey: WEBHOOK_KEY,
      });
      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 {
        statusCode: 401,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
        body: JSON.stringify({ error: 'Webhook verification failed' })
      };
    }

    // Initialize Neon client
    const sql = neon(DATABASE_URL);

    const payload: WebhookPayload = JSON.parse(rawBody);
    const eventType = payload.type;
    const eventData = payload.data;
    const webhookId = event.headers['webhook-id'] || '';

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

    // Check for duplicate webhook-id (idempotency)
    if (webhookId) {
      const existingEvent = await sql`
        SELECT id FROM webhook_events WHERE webhook_id = ${webhookId}
      `;

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

    // Log webhook event with webhook_id for idempotency
    const logResult = await sql`
      INSERT INTO webhook_events (webhook_id, event_type, data, processed, created_at)
      VALUES (${webhookId || null}, ${eventType}, ${JSON.stringify(eventData)}, ${false}, ${new Date().toISOString()})
      RETURNING id
    `;

    const loggedEventId = logResult[0].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(sql, payload, 'active');
          break;
        case 'subscription.cancelled':
          await handleSubscriptionEvent(sql, payload, 'cancelled');
          break;
        case 'subscription.renewed':
          console.log('🔄 Subscription renewed - keeping active status and updating billing date');
          await handleSubscriptionEvent(sql, payload, 'active');
          break;
        default:
          console.log(`ℹ️ Event ${eventType} logged but not processed (no handler available)`);
      }

      await sql`
        UPDATE webhook_events 
        SET processed = ${true}, processed_at = ${new Date().toISOString()}
        WHERE id = ${loggedEventId}
      `;

      console.log('✅ Webhook marked as processed');
    } catch (processingError) {
      console.error('❌ Error processing webhook event:', processingError);

      await sql`
        UPDATE webhook_events 
        SET processed = ${false}, 
            error_message = ${processingError instanceof Error ? processingError.message : 'Unknown error'},
            processed_at = ${new Date().toISOString()}
        WHERE id = ${loggedEventId}
      `;

      throw processingError;
    }

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

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

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

Cách hoạt động

Trình xử lý webhook:
  1. Xác minh chữ ký - Đảm bảo yêu cầu đến từ DodoPayments bằng HMAC-SHA256
  2. Kiểm tra trùng lặp - Sử dụng ID webhook để ngăn chặn việc xử lý cùng một sự kiện hai lần
  3. Ghi lại sự kiện - Lưu trữ webhook thô trong bảng webhook_events để theo dõi
  4. Xử lý sự kiện - Tạo hoặc cập nhật khách hàng và đăng ký trong Neon
  5. Xử lý lỗi - Ghi lại các lỗi và đánh dấu sự kiện là chưa được xử lý để thử lại

Kiểm tra

Phát triển cục bộ:
npm run dev  # Available at http://localhost:8888/.netlify/functions/webhook
Xem nhật ký trong Bảng điều khiển Netlify:
  1. Chọn trang web của bạn → tab Functions
  2. Nhấp vào hàm webhook
  3. Xem nhật ký theo thời gian thực và lịch sử gọi hàm
Cấu hình trong Bảng điều khiển DodoPayments:
  1. Đi đến Nhà phát triển → Webhooks
  2. Thêm điểm cuối với URL Netlify Functions của bạn
  3. Kích hoạt: subscription.active, subscription.cancelled, subscription.renewed

Vấn đề thường gặp

Vấn đềGiải pháp
Xác minh không thành côngKiểm tra khóa webhook từ bảng điều khiển DodoPayments là chính xác
Lỗi kết nối cơ sở dữ liệuXác minh chuỗi kết nối Neon và sử dụng kết nối được phân phối
Không tìm thấy hàm (404)Chạy netlify initnpm run deploy lại
Biến môi trường không khả dụngThiết lập biến trong CLI hoặc bảng điều khiển, sau đó triển khai lại

Tài nguyên