> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dodopayments.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Cloudflare Workers

> Implante webhooks do DodoPayments no Cloudflare Workers

<Card title="GitHub Repository" icon="github" href="https://github.com/dodopayments/cloud-functions/tree/main/cloudflare">
  Código-fonte completo e guia de configuração
</Card>

## Configuração Rápida

### 1. Pré-requisitos

* [Conta do Cloudflare](https://dash.cloudflare.com/sign-up)
* Conta do [Neon database](https://neon.com)
* Chave da API do DodoPayments do [painel](https://app.dodopayments.com/)

### 2. Instalar Dependências

```bash theme={null}
npm install -g wrangler
wrangler login
git clone https://github.com/dodopayments/cloud-functions.git
cd cloud-functions/cloudflare
npm install
```

### 3. Configuração do Banco de Dados

1. Inscreva-se no [Neon](https://neon.com)
2. Crie um novo projeto
3. Abra o Editor SQL
4. Copie e cole o conteúdo de [`schema.sql`](#database-schema)
5. Execute a consulta
6. Obtenha sua string de conexão do Neon → Detalhes da Conexão

### 4. Configurar Segredos Iniciais

```bash theme={null}
# Set your Neon database URL
wrangler secret put DATABASE_URL

# Set your API key
wrangler secret put DODO_PAYMENTS_API_KEY
```

> **Nota:** Definiremos `DODO_PAYMENTS_WEBHOOK_KEY` após o deploy quando você tiver sua URL de webhook.

### 5. Atualizar wrangler.toml

Edite `wrangler.toml` e defina o nome do seu worker:

```toml theme={null}
name = "my-dodo-webhook"
```

### 6. Implantar

```bash theme={null}
npm run deploy
```

### 7. Obter Sua URL de Webhook

Sua URL de webhook é:

```
https://[worker-name].[your-subdomain].workers.dev
```

### 8. Registrar Webhook no Painel do DodoPayments

1. Acesse o [Painel do DodoPayments](https://app.dodopayments.com) → Desenvolvedor → Webhooks
2. Crie um novo endpoint de webhook
3. Configure sua URL de webhook como o endpoint
4. Ative estes eventos de assinatura:
   * `subscription.active`
   * `subscription.cancelled`
   * `subscription.renewed`
5. Copie o **Segredo de Assinatura**

### 9. Definir Chave de Webhook e Reimplantar

```bash theme={null}
wrangler secret put DODO_PAYMENTS_WEBHOOK_KEY
npm run deploy
```

## O Que Ele Faz

Processa eventos de assinatura e os armazena no PostgreSQL:

* **subscription.active** - Cria/atualiza registros de clientes e assinaturas
* **subscription.cancelled** - Marca a assinatura como cancelada
* **subscription.renewed** - Atualiza a próxima data de cobrança

## Principais Recursos

✅ **Verificação de assinatura** - Usando a biblioteca dodopayments\
✅ **Idempotência** - Evita processamento duplicado com IDs de webhook\
✅ **Registro de eventos** - Trilha completa de auditoria na tabela `webhook_events`\
✅ **Tratamento de erros** - Registrado e passível de nova tentativa

> **Nota:** Esta implementação demonstra o tratamento de três eventos principais de assinatura (`subscription.active`, `subscription.cancelled`, `subscription.renewed`) com campos mínimos. Você pode estendê-la facilmente para suportar tipos adicionais de eventos e campos conforme suas necessidades.

## Arquivos de Configuração

<CodeGroup>
  ```json package.json theme={null}
  {
    "name": "dodo-webhook-cloudflare",
    "version": "1.0.0",
    "type": "module",
    "description": "DodoPayments Webhook Handler for Cloudflare Workers",
    "main": "worker.ts",
    "scripts": {
      "dev": "wrangler dev",
      "deploy": "wrangler deploy",
      "tail": "wrangler tail"
    },
    "dependencies": {
      "@neondatabase/serverless": "^1.0.2",
      "dodopayments": "^2.4.1"
    },
    "devDependencies": {
      "@cloudflare/workers-types": "^4.0.0",
      "typescript": "^5.9.3",
      "wrangler": "^4.43.0"
    }
  }
  ```

  ```toml wrangler.toml expandable theme={null}
  # Cloudflare Workers Configuration
  name = "dodo-webhook"
  main = "worker.ts"
  compatibility_date = "2025-10-20"

  [observability]
  [observability.logs]
  enabled = true
  head_sampling_rate = 1
  invocation_logs = true
  persist = true
  ```

  ```json tsconfig.json theme={null}
  {
    "compilerOptions": {
      "target": "ES2022",
      "module": "ES2022",
      "lib": ["ES2022"],
      "moduleResolution": "bundler",
      "types": ["@cloudflare/workers-types"],
      "esModuleInterop": true,
      "strict": true,
      "skipLibCheck": true,
      "resolveJsonModule": true,
      "allowSyntheticDefaultImports": true,
      "forceConsistentCasingInFileNames": true,
      "isolatedModules": true
    },
    "include": ["*.ts"],
    "exclude": ["node_modules"]
  }
  ```
</CodeGroup>

## Esquema do Banco de Dados

<CodeGroup>
  ```sql schema.sql expandable theme={null}
  -- 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';
  ```
</CodeGroup>

**Tabelas criadas:**

* **customers** - Email, nome, dodo\_customer\_id
* **subscriptions** - Status, valor, next\_billing\_date, vinculado a clientes
* **webhook\_events** - Registro de eventos com webhook\_id para idempotência

## Código de Implementação

<CodeGroup>
  ```typescript worker.ts expandable theme={null}
  import { DodoPayments } from 'dodopayments';
  import { neon, NeonQueryFunction } from '@neondatabase/serverless';

  interface Env {
    DATABASE_URL: string;
    DODO_PAYMENTS_API_KEY: string;
    DODO_PAYMENTS_WEBHOOK_KEY: string;
  }

  interface WebhookPayload {
    business_id: string;
    type: string;
    timestamp: string;
    data: {
      payload_type:
        | "Payment"
        | "Subscription"
        | "Refund"
        | "Dispute"
        | "LicenseKey"
        | "CreditLedgerEntry"
        | "CreditBalanceLow"
        | "AbandonedCheckout"
        | "DunningAttempt"
        | "EntitlementGrant";
      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 default {
    async fetch(request: Request, env: Env): Promise<Response> {
      // Handle CORS preflight
      if (request.method === 'OPTIONS') {
        return new Response('ok', { headers: corsHeaders });
      }

      if (request.method !== 'POST') {
        return new Response(JSON.stringify({ error: 'Method not allowed' }), {
          status: 405,
          headers: { ...corsHeaders, 'Content-Type': 'application/json' }
        });
      }

      try {
        const rawBody = await request.text();
        console.log('📨 Webhook received');

        // Verify required environment variables
        if (!env.DODO_PAYMENTS_API_KEY) {
          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 (!env.DODO_PAYMENTS_WEBHOOK_KEY) {
          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' } }
          );
        }

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

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

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

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

        const payload: WebhookPayload = JSON.parse(rawBody);
        const eventType = payload.type;
        const eventData = payload.data;
        const webhookId = request.headers.get('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 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 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 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' } }
        );
      }
    }
  };
  ```
</CodeGroup>

## Como Funciona

O manipulador de webhook:

1. **Verifica a assinatura** - Garante que a solicitação é da DodoPayments usando HMAC-SHA256
2. **Verifica duplicatas** - Usa o ID do webhook para evitar processar o mesmo evento duas vezes
3. **Registra o evento** - Armazena o webhook bruto na tabela `webhook_events` para trilha de auditoria
4. **Processa o evento** - Cria ou atualiza clientes e assinaturas
5. **Trata erros** - Registra falhas e marca o evento como não processado para nova tentativa

## Testando

**Desenvolvimento local:**

```bash theme={null}
npm run dev  # Available at http://localhost:8787
```

**Configurar no Painel do DodoPayments:**

1. Vá para Desenvolvedores → Webhooks
2. Adicione um endpoint com sua URL de Worker
3. Ative: subscription.active, subscription.cancelled, subscription.renewed

## Problemas Comuns

| Problema                             | Solução                                                                |
| ------------------------------------ | ---------------------------------------------------------------------- |
| Falha na verificação                 | Verifique se a chave do webhook está correta no painel da DodoPayments |
| Erro de conexão com o banco de dados | Verifique a string de conexão do Neon                                  |
| Worker não está sendo implantado     | Execute `npm install`, verifique a sintaxe de `wrangler.toml`          |

## Recursos

* [Documentação do Cloudflare Workers](https://developers.cloudflare.com/workers/)
* [Documentação do Neon](https://neon.com/docs/)
* [Guia de Eventos de Webhook](/developer-resources/webhooks/intents/webhook-events-guide)
* [Repositório do GitHub](https://github.com/dodopayments/cloud-functions/tree/main/cloudflare)
