> ## 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.

# Crea un servicio de correo electrónico prepago con facturación basada en créditos

> Crea MailKit, una plataforma de correo electrónico transaccional con créditos de correo prepago. Un plan de suscripción mensual, paquetes de recarga únicos, deducción instantánea de créditos en cada envío y alertas proactivas de saldo bajo, con Resend y <Tip>.

<Tip>
  <strong>Deja que Sentra escriba tu código de integración por ti.</strong><br />
  Usa nuestro asistente de IA en VS Code, Cursor o Windsurf para generar código de SDK/API, manejadores de webhooks y mucho más, simplemente describiendo lo que necesitas.

  <a href="https://dodopayments.com/sentra" target="_blank" rel="noopener noreferrer">
    Prueba Sentra: integración con IA →
  </a>
</Tip>

En este tutorial crearás **MailKit**, una plataforma de correo electrónico transaccional en la que los clientes pagan por adelantado por un conjunto de créditos de correo. El plan concede una cantidad mensual de correos; cuando a los clientes les quedan pocos, pueden comprar un paquete de recarga en lugar de esperar al siguiente ciclo. Cada envío descuenta automáticamente un crédito.

<Note>
  En este tutorial se usa [Resend](https://resend.com) como proveedor de correo electrónico. Su nivel gratuito (3.000 correos al mes) es suficiente para crear y probar todo el flujo sin una cuenta de pago. El patrón funciona con cualquier proveedor; sustituye `resend.emails.send` por SendGrid, Postmark, SES o tu propio relay SMTP.
</Note>

Al finalizar este tutorial, sabrás cómo:

* Crear un entitlement de crédito personalizado (correos) en tu dashboard
* Asociar créditos a un plan de suscripción y a un producto de recarga único
* Enviar correos reales mediante Resend y descontar un crédito por envío mediante una entrada del ledger
* Consultar un saldo de créditos activo desde tu frontend
* Verificar correctamente los webhooks de Dodo y gestionar `credit.balance_low` para avisar a los clientes antes de que lleguen a cero

## Lo que vamos a crear

Este es el modelo de precios de MailKit:

| Producto           | Precio             | Correos             |
| ------------------ | ------------------ | ------------------- |
| Plan MailKit       | 19 \$/mes          | 5.000 correos/ciclo |
| Paquete de recarga | 9 \$ por única vez | +5.000 correos      |

La unidad es **un correo = un crédito**. Los clientes no tienen que pensar en tokens, lotes ni unidades ponderadas. Solo ven «te quedan 4.231 correos este mes».

<Info>
  Antes de empezar, asegúrate de tener:

  * Una cuenta de Dodo Payments (el modo de prueba es suficiente)
  * Una cuenta gratuita de [Resend](https://resend.com) y una API key
  * Node.js 18 o posterior y conocimientos básicos de TypeScript
</Info>

## Paso 1: Crea tu entitlement de créditos de correo

El entitlement de crédito define la unidad que vende tu plataforma: en este caso, un envío de correo.

<Frame caption="The Credits tab under Products lists all your credit entitlements.">
  <img src="https://mintcdn.com/dodopayments/Uc5BUwzydK5AJ2P-/images/CBB/Desktop%20-%20Cookbook%20-%20Credits.png?fit=max&auto=format&n=Uc5BUwzydK5AJ2P-&q=85&s=7cb0896037c7e8578bf85f2009c26837" alt="Credits listing page" style={{ maxHeight: '500px', width: 'auto' }} width="3250" height="1702" data-path="images/CBB/Desktop - Cookbook - Credits.png" />
</Frame>

<Steps>
  <Step title="Open the Credits section">
    1. Inicia sesión en tu dashboard de Dodo Payments
    2. Haz clic en **Products** en la barra lateral izquierda
    3. Selecciona la pestaña **Credits**
    4. Haz clic en **Create Credit**
  </Step>

  <Step title="Configure the credit unit">
    Completa los detalles del crédito:

    **Credit Name**: `Email Credits`

    **Credit Type**: Selecciona **Custom Unit**

    **Unit Name**: `email`

    **Precision**: `0` (un correo siempre es una unidad completa; no puedes enviar medio correo)

    **Credit Expiry**: `30 days` (la cantidad asignada de cada ciclo se restablece)

    <Warning>
      La precisión no se puede cambiar después de la creación. Para unidades discretas como correos, mensajes o sesiones, `0` es la opción correcta.
    </Warning>
  </Step>

  <Step title="Leave the other defaults as-is">
    No habilitaremos el rollover ni el overage en este cookbook; el objetivo es crear el flujo de CBB más sencillo posible. Puedes revisar estas opciones más adelante, al asociar el crédito.
  </Step>

  <Step title="Save and copy the credit ID">
    Haz clic en **Create Credit**. Abre el crédito y copia su ID. Lo necesitarás para las consultas de saldo del backend. Tiene un formato similar a `cent_xxxxxxxxxxxx`.

    <Check>
      Tu entitlement `Email Credits` está listo. A continuación: los productos que conceden créditos a los clientes.
    </Check>
  </Step>
</Steps>

## Paso 2: Crea el plan y el paquete de recarga

Crearás dos productos: un plan de **Subscription** recurrente y una recarga de **Single Payment**. El plan concede 5.000 correos en cada ciclo; la recarga añade otros 5.000 bajo demanda. Ambos asocian el mismo entitlement `Email Credits`.

<Tip>
  Este cookbook descuenta créditos mediante entradas directas del ledger en lugar de meters basados en el uso. Las entradas del ledger son inmediatas (el saldo se actualiza en milisegundos), no requieren configuración adicional y son adecuadas cuando una acción del usuario equivale exactamente a un crédito. Si prefieres la deducción automática a partir de eventos de uso ingeridos (útil para unidades ponderadas como «tokens» o «MB procesados»), consulta [Credit-Based Billing → Usage Billing with Credits](/features/credit-based-billing) para ver el patrón basado en meters.
</Tip>

### Plan MailKit (19 \$/mes, 5.000 correos)

<Steps>
  <Step title="Create the subscription">
    1. Ve a **Products → Create Product**
    2. Completa los detalles del producto:

    **Product Name**: `MailKit Plan`

    **Description**: `5,000 transactional emails per month.`

    3. Selecciona **Subscription** como tipo de producto
    4. Establece el precio recurrente:

    **Recurring Price**: `19.00`

    **Billing Cycle**: `Monthly`

    **Currency**: `USD`
  </Step>

  <Step title="Attach the email credit entitlement">
    Desplázate hasta **Entitlements → Credits → Attach** y configura lo siguiente:

    **Credit Entitlement**: `Email Credits`

    **Credits issued per billing cycle**: `5000`

    **Low Balance Threshold**: `20` (porcentaje; activa `credit.balance_low` cuando el saldo cae por debajo del 20 % de la cantidad asignada en el ciclo, es decir, 1.000 correos)

    **Import Default Credit Settings**: habilitado (usa la caducidad de 30 días del paso 1)

    Haz clic en **Add to Product** y, después, en **Save** para guardar el producto. Copia el ID del producto (`pdt_xxxxxxxxxxxx`).

    <Check>
      Plan: 19 \$/mes → 5.000 correos renovados en cada ciclo.
    </Check>
  </Step>
</Steps>

### Paquete de recarga (9 \$ por única vez, 5.000 correos)

<Steps>
  <Step title="Create a one-time product">
    1. Ve a **Products → Create Product**
    2. Completa los detalles del producto:

    **Product Name**: `Email Top-Up Pack`

    **Description**: `Add 5,000 emails to your MailKit balance instantly.`

    3. Selecciona **Single Payment** como tipo de producto
    4. Establece el precio:

    **Price**: `9.00`

    **Currency**: `USD`
  </Step>

  <Step title="Attach the credit grant">
    En **Entitlements → Credits → Attach**:

    * Credit Entitlement: `Email Credits`
    * Credits issued: `5000`

    <Info>
      Los productos únicos conceden créditos con su propia caducidad (30 días desde la compra, según el paso 1). Las recargas se acumulan sobre los créditos de la suscripción; no los reemplazan.
    </Info>

    Guarda y copia el ID del producto.

    <Check>
      Paquete de recarga: 9 \$ → +5.000 correos, disponibles inmediatamente.
    </Check>
  </Step>
</Steps>

## Paso 3: Configura el backend

Ahora crea el servidor Express que gestionará el checkout, los envíos, las consultas de saldo y los webhooks.

<Steps>
  <Step title="Initialize the project">
    ```bash theme={null}
    mkdir mailkit && cd mailkit
    npm init -y
    npm install dodopayments resend express dotenv
    npm install -D tsx @types/node @types/express
    ```

    Añade un script de desarrollo a `package.json`:

    ```json theme={null}
    {
      "scripts": {
        "dev": "tsx watch server.ts"
      }
    }
    ```

    <Tip>
      [`tsx`](https://tsx.is) ejecuta TypeScript directamente sin un paso de compilación ni `tsconfig.json`, lo que resulta perfecto para un tutorial. En producción, añade un `tsconfig.json` y un script `build`.
    </Tip>
  </Step>

  <Step title="Configure environment variables">
    Crea `.env`:

    ```bash .env theme={null}
    # Dodo Payments
    DODO_PAYMENTS_API_KEY=your_dodo_test_api_key
    DODO_WEBHOOK_KEY=your_dodo_webhook_signing_key
    CREDIT_ENTITLEMENT_ID=cent_xxxxxxxxxxxx
    PLAN_PRODUCT_ID=pdt_xxxxxxxxxxxx
    TOPUP_PRODUCT_ID=pdt_xxxxxxxxxxxx

    # Resend
    RESEND_API_KEY=re_xxxxxxxxxxxx

    # App
    BASE_URL=http://localhost:3000
    PORT=3000
    ```

    Completarás `DODO_WEBHOOK_KEY` en el paso 4, después de crear el endpoint. La API key de Resend se obtiene en [resend.com/api-keys](https://resend.com/api-keys).

    <Warning>
      Añade `.env` a `.gitignore` inmediatamente. No confirmes las API keys en el repositorio.
    </Warning>
  </Step>

  <Step title="Build the server">
    Crea `server.ts` en la raíz del proyecto:

    <CodeGroup>
      ```typescript server.ts expandable theme={null}
      import 'dotenv/config';
      import express, { Request, Response } from 'express';
      import DodoPayments from 'dodopayments';
      import { Resend } from 'resend';

      const app = express();

      const dodo = new DodoPayments({
        bearerToken: process.env.DODO_PAYMENTS_API_KEY!,
        webhookKey: process.env.DODO_WEBHOOK_KEY!,
        environment: 'test_mode',
      });

      const resend = new Resend(process.env.RESEND_API_KEY!);

      const CREDIT_ENTITLEMENT_ID = process.env.CREDIT_ENTITLEMENT_ID!;
      const BASE_URL = process.env.BASE_URL!;

      // ---------------------------------------------------------------
      // Webhook endpoint MUST receive the raw body for signature
      // verification. Register it BEFORE express.json().
      // ---------------------------------------------------------------
      app.post(
        '/webhooks/dodo',
        express.raw({ type: 'application/json' }),
        async (req: Request, res: Response) => {
          const headers = {
            'webhook-id': req.headers['webhook-id'] as string,
            'webhook-signature': req.headers['webhook-signature'] as string,
            'webhook-timestamp': req.headers['webhook-timestamp'] as string,
          };

          let event: any;
          try {
            event = await dodo.webhooks.unwrap(req.body.toString('utf8'), { headers });
          } catch (err) {
            console.error('Webhook signature verification failed:', err);
            return res.status(401).json({ error: 'invalid signature' });
          }

          switch (event.type) {
            case 'credit.balance_low': {
              const { customer_id, credit_entitlement_name, available_balance, threshold_percent } =
                event.data;
              console.log(
                `[low-balance] ${customer_id} has ${available_balance} ${credit_entitlement_name} ` +
                  `left (under ${threshold_percent}%)`
              );
              await notifyCustomerLowBalance(customer_id, Number(available_balance));
              break;
            }
            case 'credit.added':
              console.log('[credit.added]', event.data);
              break;
            case 'credit.rolled_over':
              console.log('[rolled_over]', event.data);
              break;
          }

          res.json({ received: true });
        }
      );

      // JSON parsing for everything else.
      app.use(express.json());

      // ---------------------------------------------------------------
      // POST /checkout/subscribe → start the MailKit subscription.
      // ---------------------------------------------------------------
      app.post('/checkout/subscribe', async (req, res) => {
        const { email, name } = req.body as { email: string; name: string };

        const session = await dodo.checkoutSessions.create({
          product_cart: [{ product_id: process.env.PLAN_PRODUCT_ID!, quantity: 1 }],
          customer: { email, name },
          return_url: `${BASE_URL}/?subscribed=1`,
        });

        res.json({ checkout_url: session.checkout_url });
      });

      // ---------------------------------------------------------------
      // POST /checkout/topup → buy a 5,000-email top-up for an existing
      // customer. In a real app, customer_id is resolved from the
      // authenticated session, never trusted from request input.
      // ---------------------------------------------------------------
      app.post('/checkout/topup', async (req, res) => {
        const { customer_id } = req.body as { customer_id: string };

        const session = await dodo.checkoutSessions.create({
          product_cart: [{ product_id: process.env.TOPUP_PRODUCT_ID!, quantity: 1 }],
          customer: { customer_id },
          return_url: `${BASE_URL}/?topped_up=1`,
        });

        res.json({ checkout_url: session.checkout_url });
      });

      // ---------------------------------------------------------------
      // GET /credits/:customerId → live balance for the dashboard widget.
      // ---------------------------------------------------------------
      app.get('/credits/:customerId', async (req, res) => {
        const balance = await dodo.creditEntitlements.balances.retrieve(req.params.customerId, {
          credit_entitlement_id: CREDIT_ENTITLEMENT_ID,
        });

        res.json({ balance: balance.balance });
      });

      // ---------------------------------------------------------------
      // POST /send → send an email via Resend, then write a ledger entry
      // to debit 1 credit from the customer's balance. The deduction is
      // instant; the next /credits call reflects it.
      // ---------------------------------------------------------------
      app.post('/send', async (req, res) => {
        const { customer_id, to, subject, html } = req.body as {
          customer_id: string;
          to: string;
          subject: string;
          html: string;
        };

        // 1. Pre-flight balance check: refuse to send if the balance is at zero.
        const balance = await dodo.creditEntitlements.balances.retrieve(customer_id, {
          credit_entitlement_id: CREDIT_ENTITLEMENT_ID,
        });

        if (Number(balance.balance) <= 0) {
          return res.status(402).json({
            error: 'No email credits remaining. Buy a top-up pack or upgrade your plan.',
          });
        }

        // 2. Send via Resend.
        const { data, error } = await resend.emails.send({
          from: 'MailKit <onboarding@resend.dev>', // swap for your verified domain
          to: [to],
          subject,
          html,
        });

        if (error) {
          return res.status(500).json({ error: error.message });
        }

        // 3. Debit 1 credit. Resend's message id is the idempotency key, so if
        //    the client retries this request, Dodo deduplicates and the
        //    customer is only debited once for that send.
        await dodo.creditEntitlements.balances.createLedgerEntry(customer_id, {
          credit_entitlement_id: CREDIT_ENTITLEMENT_ID,
          amount: '1',
          entry_type: 'debit',
          reason: `email send ${data!.id}`,
          idempotency_key: data!.id,
        });

        res.json({ id: data!.id });
      });

      async function notifyCustomerLowBalance(customerId: string, available: number) {
        // In production: send an email to the account owner, push a banner,
        // open an in-app modal, etc. For the demo we just log.
        console.log(`[NOTIFY] ${customerId}: ${available} emails left. Consider topping up.`);
      }

      app.use(express.static('public'));

      const port = Number(process.env.PORT) || 3000;
      app.listen(port, () => {
        console.log(`MailKit running on http://localhost:${port}`);
      });
      ```
    </CodeGroup>

    <Warning>
      **El cuerpo del webhook debe ser raw.** `express.json()` analiza y vuelve a serializar el cuerpo, lo que rompe la verificación de la firma. Define `/webhooks/dodo` con `express.raw()` *antes* de la línea `app.use(express.json())`.
    </Warning>

    <Check>
      Backend listo: suscripción, recarga, saldo, envío y manejador de webhooks conectados.
    </Check>
  </Step>

  <Step title="Add a demo UI">
    Crea `public/index.html`:

    <CodeGroup>
      ```html public/index.html expandable theme={null}
      <!doctype html>
      <html>
        <head>
          <title>MailKit Demo</title>
          <style>
            body {
              font-family: system-ui, -apple-system, sans-serif;
              max-width: 720px;
              margin: 40px auto;
              padding: 0 20px;
              color: #1a1a2e;
            }
            h1 { font-size: 28px; margin-bottom: 4px; }
            h2 { font-size: 16px; margin-top: 32px; padding-bottom: 6px; border-bottom: 1px solid #eee; }
            label { display: block; font-size: 13px; font-weight: 600; margin: 12px 0 4px; }
            input, select, textarea {
              width: 100%;
              padding: 10px;
              border: 1px solid #ddd;
              border-radius: 6px;
              font-family: inherit;
              font-size: 14px;
              box-sizing: border-box;
            }
            button {
              background: #1a1a2e;
              color: white;
              padding: 10px 18px;
              border: none;
              border-radius: 6px;
              cursor: pointer;
              font-size: 14px;
              margin-top: 12px;
            }
            button:hover { background: #2d2d4a; }
            .out {
              background: #f6f6fa;
              padding: 12px;
              border-radius: 6px;
              margin-top: 12px;
              font-size: 13px;
              font-family: ui-monospace, monospace;
              white-space: pre-wrap;
              word-break: break-all;
            }
            .balance { font-size: 36px; font-weight: 700; color: #4f46e5; }
            .balance-sub { color: #888; font-size: 13px; margin-top: 4px; }
          </style>
        </head>
        <body>
          <h1>MailKit</h1>
          <p>Prepaid transactional email, billed per send.</p>

          <h2>1. Subscribe to MailKit ($19/mo, 5,000 emails)</h2>
          <label>Email</label>
          <input id="subEmail" type="email" placeholder="you@example.com" />
          <label>Name</label>
          <input id="subName" type="text" placeholder="Your name" />
          <button onclick="subscribe()">Get checkout link</button>
          <div id="subOut" class="out" hidden></div>

          <h2>2. Check your balance</h2>
          <label>Customer ID</label>
          <input id="balCust" type="text" placeholder="cus_xxxxxxxxxxxx" />
          <button onclick="checkBalance()">Refresh</button>
          <div id="balOut" class="out" hidden></div>

          <h2>3. Send a transactional email</h2>
          <label>Customer ID</label>
          <input id="sendCust" type="text" placeholder="cus_xxxxxxxxxxxx" />
          <label>To (Resend's sandbox accepts delivered@resend.dev)</label>
          <input id="sendTo" type="email" value="delivered@resend.dev" />
          <label>Subject</label>
          <input id="sendSubj" type="text" value="Hello from MailKit" />
          <label>HTML body</label>
          <textarea id="sendBody" rows="3">&lt;strong&gt;It works!&lt;/strong&gt;</textarea>
          <button onclick="sendEmail()">Send</button>
          <div id="sendOut" class="out" hidden></div>

          <h2>4. Run low? Buy a top-up pack</h2>
          <label>Customer ID</label>
          <input id="topCust" type="text" placeholder="cus_xxxxxxxxxxxx" />
          <button onclick="topup()">Buy 5,000 emails ($9)</button>
          <div id="topOut" class="out" hidden></div>

          <script>
            const show = (id, content) => {
              const el = document.getElementById(id);
              el.hidden = false;
              el.innerHTML = content;
            };

            async function subscribe() {
              const r = await fetch('/checkout/subscribe', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                  email: document.getElementById('subEmail').value,
                  name: document.getElementById('subName').value,
                }),
              });
              const data = await r.json();
              show('subOut', r.ok
                ? `<a href="${data.checkout_url}" target="_blank">Open checkout →</a>`
                : `Error: ${data.error}`);
            }

            async function checkBalance() {
              const id = document.getElementById('balCust').value;
              const r = await fetch(`/credits/${id}`);
              const data = await r.json();
              show('balOut', r.ok
                ? `<div class="balance">${Number(data.balance).toLocaleString()}</div>
                   <div class="balance-sub">emails available</div>`
                : `Error: ${data.error}`);
            }

            async function sendEmail() {
              const r = await fetch('/send', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                  customer_id: document.getElementById('sendCust').value,
                  to: document.getElementById('sendTo').value,
                  subject: document.getElementById('sendSubj').value,
                  html: document.getElementById('sendBody').value,
                }),
              });
              const data = await r.json();
              show('sendOut', r.ok ? `Sent. Message id: ${data.id}` : `Error: ${data.error}`);
            }

            async function topup() {
              const r = await fetch('/checkout/topup', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ customer_id: document.getElementById('topCust').value }),
              });
              const data = await r.json();
              show('topOut', r.ok
                ? `<a href="${data.checkout_url}" target="_blank">Open top-up checkout →</a>`
                : `Error: ${data.error}`);
            }
          </script>
        </body>
      </html>
      ```
    </CodeGroup>
  </Step>
</Steps>

## Paso 4: Conecta el endpoint del webhook

El evento `credit.balance_low` permite avisar a los clientes *antes* de que se queden sin créditos. Sin él, se darán cuenta del problema por primera vez cuando un correo no pueda enviarse.

<Steps>
  <Step title="Expose your local server">
    Los webhooks necesitan una URL pública. Usa [ngrok](https://ngrok.com) (o cualquier túnel) durante el desarrollo:

    ```bash theme={null}
    ngrok http 3000
    ```

    Copia la URL de reenvío HTTPS (por ejemplo, `https://1234abcd.ngrok-free.app`).
  </Step>

  <Step title="Register the endpoint in Dodo">
    1. Ve a **Developers → Webhooks → Add Endpoint**
    2. **URL**: `https://1234abcd.ngrok-free.app/webhooks/dodo`
    3. **Events**: suscríbete a `credit.added`, `credit.balance_low` y `credit.rolled_over`
    4. Guarda y copia la **signing key** en tu `.env` como `DODO_WEBHOOK_KEY`
    5. Reinicia el servidor
  </Step>
</Steps>

## Paso 5: Prueba el flujo completo

<Steps>
  <Step title="Start the server">
    ```bash theme={null}
    npm run dev
    ```

    Deberías ver `MailKit running on http://localhost:3000`. Ábrelo en tu navegador.
  </Step>

  <Step title="Subscribe a test customer">
    1. En la sección 1, introduce un correo de prueba y un nombre, y haz clic en **Get checkout link**
    2. Abre el enlace y completa el checkout con una [tarjeta de prueba](/miscellaneous/testing-process)
    3. Después del pago, busca `customer_id` en tu dashboard, dentro de **Customers**

    <Check>
      El cliente debería tener ahora **5.000 correos** en su saldo. Compruébalo en **Customers → \[Customer] → Credits**.
    </Check>
  </Step>

  <Step title="Send a real email">
    1. Pega `customer_id` en la sección 3
    2. Deja `to` establecido en `delivered@resend.dev` (la bandeja de entrada de pruebas de Resend que acepta todo)
    3. Haz clic en **Send**

    Recibirás el ID del mensaje de Resend. Actualiza el saldo en la sección 2 y la cantidad bajará inmediatamente a 4.999. Cada débito del ledger se refleja en el saldo activo en el momento en que se registra.
  </Step>

  <Step title="Trigger the low-balance webhook">
    El umbral es del 20 % (1.000 de los 5.000 correos asignados). Para activarlo sin enviar 4.000 correos reales, **debita manualmente el saldo** desde el dashboard:

    1. Ve a **Customers → \[Customer] → Credits → Email Credits**
    2. Haz clic en **Adjust Balance** y debita `4000`
    3. Envía un correo más mediante la demo

    Tu servidor debería registrar lo siguiente en unos segundos:

    ```
    [low-balance] cus_xxx has 999 Email Credits left (under 20%)
    [NOTIFY] cus_xxx: 999 emails left. Consider topping up.
    ```

    <Check>
      Tu servidor recibió y verificó el webhook. En producción, aquí enviarías un correo al cliente o mostrarías un banner dentro de la aplicación.
    </Check>
  </Step>

  <Step title="Buy a top-up pack">
    1. Pega `customer_id` en la sección 4
    2. Haz clic en **Buy 5,000 emails** y completa el checkout de prueba
    3. Actualiza el saldo: aumentará en 5.000

    <Check>
      Se activa un evento `credit.added` con `grant_source: one_time`. La recarga se acumula sobre los créditos de la suscripción; ambos grupos se consumen en orden FIFO (primero se utiliza la asignación no caducada más antigua).
    </Check>
  </Step>

  <Step title="Test the hard stop">
    Debita manualmente el saldo hasta cero y, después, intenta enviar otro correo. Obtendrás:

    ```json theme={null}
    { "error": "No email credits remaining. Buy a top-up pack or upgrade your plan." }
    ```

    Ese 402 es la aplicación de la lógica de enforcement de tu aplicación. La API de saldo de Dodo es la fuente de verdad; nunca la almacenes en caché en el cliente.
  </Step>
</Steps>

## Solución de problemas

<AccordionGroup>
  <Accordion title="Webhook signature verification fails (401)">
    La firma se calcula sobre el cuerpo HTTP raw. `express.json()` analiza y vuelve a serializar el payload, lo que rompe el HMAC. Asegúrate de que `/webhooks/dodo` esté registrado con `express.raw({ type: 'application/json' })` *por encima* de la línea `app.use(express.json())` y de que `DODO_WEBHOOK_KEY` coincida con la signing key que aparece en la página de detalles del endpoint.
  </Accordion>

  <Accordion title="Balance is 0, customer not found, or credits don't deduct">
    Comprueba estas tres cosas, en este orden:

    1. El cliente **completó el checkout** (los créditos se conceden cuando el pago se realiza correctamente, no al crear la sesión)
    2. `CREDIT_ENTITLEMENT_ID` en tu `.env` coincide con el crédito asociado al producto (los ID que no coinciden escriben silenciosamente en el crédito equivocado)
    3. `customer_id` que estás pasando procede de Dodo (la tabla `customers` del dashboard), no de tu propia base de datos
  </Accordion>

  <Accordion title="Resend rejects the recipient">
    El remitente de pruebas `onboarding@resend.dev` solo envía al correo de tu cuenta de Resend o a `delivered@resend.dev`. Para enviar a cualquier otra persona, [verifica un dominio](https://resend.com/docs/dashboard/domains/introduction) y usa una dirección `from` en él.
  </Accordion>
</AccordionGroup>

## Lo que has creado

<CardGroup cols={2}>
  <Card title="One reusable credit unit" icon="envelope">
    `Email Credits`, definido una sola vez y asociado tanto al plan de suscripción como al paquete de recarga.
  </Card>

  <Card title="Subscription with prepaid allowance" icon="layer-group">
    19 \$/mes concede 5.000 correos por ciclo. Los clientes saben por qué pagan y tú conoces tu coste máximo.
  </Card>

  <Card title="Top-up pack" icon="circle-plus">
    Un producto único que concede 5.000 correos. Se acumula sobre los créditos de la suscripción sin necesidad de cambiar de plan.
  </Card>

  <Card title="Instant ledger debits" icon="bolt">
    Una única llamada `createLedgerEntry` después de cada envío. Sin meter ni retraso de agregación; es idempotente al reintentarse mediante el ID del mensaje de Resend.
  </Card>
</CardGroup>

<Card title="Credit-Based Billing Reference" icon="book" href="/features/credit-based-billing">
  Consulta la documentación completa de CBB para obtener información sobre rollover, modos de overage, gestión del ledger y toda la API.
</Card>

¿Necesitas ayuda?

* [Comunidad de Discord](https://discord.gg/bYqAp4ayYh)
* [support@dodopayments.com](mailto:support@dodopayments.com)
