Vai al contenuto principale

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.

Fai scrivere il tuo codice d’integrazione a Sentra.
Usa il nostro assistente AI in VS Code, Cursor, o Windsurf per generare codice SDK/API, gestori di webhook e molto altro, semplicemente descrivendo ciò di cui hai bisogno.
Prova Sentra: Integrazione Potenziata da AI →
In questo tutorial, costruirai MailKit, una piattaforma di email transazionali dove i clienti pagano in anticipo per un pool di crediti email. Il piano concede un’indennità mensile di email; quando i clienti sono a corto, possono acquistare un pacchetto di ricarica invece di aspettare il prossimo ciclo. Ogni invio detrae automaticamente un credito.
Questo tutorial utilizza Resend come fornitore di email. Il suo livello gratuito (3,000 email/mese) è sufficiente per costruire e testare l’intero flusso senza un account a pagamento. Il modello funziona con qualsiasi fornitore; sostituisci resend.emails.send con SendGrid, Postmark, SES o il tuo relay SMTP.
Alla fine di questo tutorial, saprai come:
  • Creare un’indennità di credito personalizzata (email) nel tuo dashboard
  • Allegare crediti a un piano di abbonamento e a un prodotto di ricarica una tantum
  • Inviare email reali tramite Resend e addebitare un credito per ogni invio tramite un’entrata nel registro
  • Effettuare una query di saldo credito live dal tuo frontend
  • Verificare correttamente i webhook di Dodo e gestire credit.balance_low per avvisare i clienti prima che finiscano i crediti

Cosa Stiamo Costruendo

Ecco il modello di prezzo per MailKit:
ProdottoPrezzoEmail
Piano MailKit$19/mese5,000 email/ciclo
Pacchetto Ricarica$9 una tantum+5,000 email
L’unità è una email = un credito. I clienti non devono pensare a token, lotti, o unità ponderate. Vedono semplicemente “ti restano 4,231 email questo mese.”
Prima di iniziare, assicurati di avere:
  • Un account Dodo Payments (va bene anche la modalità test)
  • Un account gratuito Resend e una chiave API
  • Node.js 18+ e familiarità di base con TypeScript

Passo 1: Crea la Tua Indennità di Credito Email

L’indennità di credito definisce l’unità che la tua piattaforma vende: in questo caso, un invio di email.
Pagina di elenco dei crediti
  1. Accedi al tuo dashboard Dodo Payments
  2. Clicca su Prodotti nella barra laterale sinistra
  3. Seleziona la scheda Crediti
  4. Clicca su Crea Credito
Compila i dettagli del credito: Nome Credito: Email Credits Tipo di Credito: Seleziona Unità Personalizzata Nome Unità: email Precisione: 0 (un’email è sempre un’unità intera; non puoi inviare mezza email) Scadenza Credito: 30 days (il credito si reimposta ogni ciclo) La precisione non può essere modificata dopo la creazione. Per unità discrete come email, messaggi o sessioni, 0 è corretto. Non abiliteremo rollover o sovraccarico in questo tutorial; l’obiettivo è il flusso CBB più semplice possibile. Puoi rivedere questi aspetti sull’attacco del credito in seguito. Clicca su Crea Credito. Apri il credito e copia il suo ID. Ti servirà per le query di saldo backend. Sembrerà con cent_xxxxxxxxxxxx. Il tuo Email Credits è pronto. Successivo: i prodotti che concedono crediti ai clienti.

Passo 2: Crea il Piano e il Pacchetto Ricarica

Creerai due prodotti: un piano di Abbonamento ricorrente e una ricarica Pagamento Singolo. Il piano concede 5,000 email per ciclo; la ricarica aggiunge altre 5,000 a richiesta. Entrambi allegano lo stesso Email Credits. Questo tutorial detrae crediti con entrate dirette nel registro anziché contatori basati sull’uso. Le entrate nel registro sono immediate (il saldo si aggiorna in millisecondi), non richiedono configurazioni aggiuntive e sono adatte quando un’azione utente equivale esattamente a un credito. Se preferisci la detrazione automatica da eventi di uso trattati (utile per unità ponderate come “token” o “MB processati”), vedi Fatturazione a Credito → Fatturazione dell’Uso con Crediti per il modello basato su contatore.

Piano MailKit ($19/mese, 5,000 email)

  1. Vai su Prodotti → Crea Prodotto
  2. Compila i dettagli del prodotto:
Nome Prodotto: MailKit Plan Descrizione: 5,000 transactional emails per month.
  1. Seleziona Abbonamento come tipo di prodotto
  2. Imposta il prezzo ricorrente:
Prezzo ricorrente: 19.00 Ciclo di Fatturazione: Monthly Valuta: USD Scorri fino a Indennità → Crediti → Allegare e configura: Indennità di Credito: Email Credits Crediti emessi per ciclo di fatturazione: 5000 Soglia di Saldo Basso: 20 (percentuale; attiva credit.balance_low quando il saldo scende sotto il 20% dell’indennità del ciclo, cioè 1,000 email) Importa Impostazioni di Credito Predefinite: abilitato (usa la scadenza di 30 giorni dal Passo 1) Clicca su Aggiungi al Prodotto, quindi Salva il prodotto. Copia l’ID del prodotto (pdt_xxxxxxxxxxxx). Piano: $19/mese → 5,000 email rinnovate ogni ciclo.

Pacchetto Ricarica ($9 una tantum, 5,000 email)

  1. Vai su Prodotti → Crea Prodotto
  2. Compila i dettagli del prodotto:
Nome Prodotto: Email Top-Up Pack Descrizione: Add 5,000 emails to your MailKit balance instantly.
  1. Seleziona Pagamento Singolo come tipo di prodotto
  2. Imposta il prezzo:
Prezzo: 9.00 Valuta: USD In Indennità → Crediti → Allegare:
  • Indennità di Credito: Email Credits
  • Crediti emessi: 5000
I prodotti una tantum concedono crediti con una propria scadenza (30 giorni dall’acquisto, per il Passo 1). Le ricariche si sommano ai crediti di abbonamento; non li sostituiscono. Salva e copia l’ID del prodotto. Pacchetto Ricarica: $9 → +5,000 email, disponibile immediatamente.

Passo 3: Configura il Backend

Ora costruisci il server Express che gestisce il checkout, l’invio, le query di saldo e i webhook.
mkdir mailkit && cd mailkit
npm init -y
npm install dodopayments resend express dotenv
npm install -D tsx @types/node @types/express
Aggiungi uno script di sviluppo a package.json:
{
  "scripts": {
    "dev": "tsx watch server.ts"
  }
}
tsx esegue TypeScript direttamente senza un passaggio di build o tsconfig.json, perfetto per un tutorial. Per la produzione, aggiungi un tsconfig.json e uno script build. Crea .env:
.env
# 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
Compila DODO_WEBHOOK_KEY nel Passo 4 dopo aver creato l’endpoint. La chiave API di Resend proviene da resend.com/api-keys. Aggiungi .env a .gitignore immediatamente. Non commettere mai chiavi API. Crea server.ts nella root del progetto:
server.ts
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}`);
});
Il corpo del webhook deve essere grezzo. express.json() analizza e riserializza il corpo, il che interrompe la verifica della firma. Definisci /webhooks/dodo con express.raw() prima della riga app.use(express.json()). Backend pronto: abbonamento, ricarica, saldo, invio e gestore di webhook tutto collegato. Crea public/index.html:
public/index.html
<!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>

Passo 4: Collega l’Endpoint Webhook

L’evento credit.balance_low ti consente di avvisare i clienti prima che finiscano i crediti. Senza di esso, la prima volta che notano il problema è quando un’email fallisce l’invio. I webhook necessitano un URL pubblico. Usa ngrok (o qualsiasi tunnel) durante lo sviluppo:
ngrok http 3000
Copia l’URL di inoltro HTTPS (ad es. https://1234abcd.ngrok-free.app).
  1. Vai su Sviluppatori → Webhook → Aggiungi Endpoint
  2. URL: https://1234abcd.ngrok-free.app/webhooks/dodo
  3. Eventi: iscriviti a credit.added, credit.balance_low e credit.rolled_over
  4. Salva, poi copia la chiave di firma nel tuo .env come DODO_WEBHOOK_KEY
  5. Riavvia il tuo server

Passo 5: Testa il Flusso Completo

npm run dev
Dovresti vedere MailKit running on http://localhost:3000. Aprilo nel tuo browser.
  1. Nella sezione 1, inserisci un’email e un nome di prova, clicca Ottieni link per il checkout
  2. Apri il link, completa il checkout con una carta di prova
  3. Dopo il pagamento, trova l’customer_id nel tuo dashboard sotto Clienti
Il cliente dovrebbe ora avere 5,000 email nel suo saldo. Controlla Clienti → [Cliente] → Crediti.
  1. Incolla l’customer_id nella sezione 3
  2. Lascia to impostato su delivered@resend.dev (la casella sandbox di Resend che accetta tutto)
  3. Clicca Invia
Riceverai un id messaggio di Resend indietro. Aggiorna il saldo nella sezione 2 e il conteggio scende immediatamente a 4,999. Ogni addebito nel registro si riflette nel saldo live nel momento in cui è scritto. La soglia è il 20% (1,000 delle 5,000 email di indennità). Per attivarla senza inviare 4,000 email reali, addebitare manualmente il saldo dal dashboard:
  1. Vai su Clienti → [Cliente] → Crediti → Crediti Email
  2. Clicca Regola Saldo e addebita 4000
  3. Invia un’altra email attraverso la demo
Il tuo server dovrebbe registrare entro pochi secondi: Il tuo server ha ricevuto e verificato il webhook. In produzione, è qui che invieresti un’email al cliente o mostreresti un banner in-app.
  1. Incolla l’customer_id nella sezione 4
  2. Clicca Compra 5,000 email, completa il checkout di prova
  3. Aggiorna il saldo, e aumenta di 5,000
Un evento credit.added si attiva con grant_source: one_time. La ricarica si somma ai crediti di abbonamento; entrambi i pool vengono consumati FIFO (il più vecchio non scaduto per primo). Addebita manualmente il saldo a zero, quindi prova a inviare un’altra email. Riceverai:
{ "error": "No email credits remaining. Buy a top-up pack or upgrade your plan." }
Quel 402 è la tua applicazione di livello esecutivo. L’API del saldo Dodo è la fonte di verità; non memorizzarla mai sul client.

Risoluzione dei Problemi

La firma è calcolata sul corpo HTTP grezzo. express.json() analizza e riserializza il payload, interrompendo l’HMAC. Assicurati che /webhooks/dodo sia registrato con express.raw({ type: 'application/json' }) sopra la riga app.use(express.json()), e che DODO_WEBHOOK_KEY corrisponda alla chiave di firma mostrata sulla pagina del dettaglio dell’endpoint. Tre cose da verificare, in questo ordine:
  1. Il cliente ha completato il checkout (i crediti vengono emessi al pagamento riuscito, non alla creazione della sessione)
  2. Il CREDIT_ENTITLEMENT_ID nel tuo .env corrisponde al credito allegato al prodotto (ID non corrispondenti scrivono silenziosamente al credito sbagliato)
  3. L’customer_id che stai passando proviene da Dodo (la tabella customers nel dashboard), non dal tuo database
Il mittente sandbox onboarding@resend.dev consegna solo all’email sul tuo account Resend o a delivered@resend.dev. Per inviare a qualcun altro, verifica un dominio e utilizza un indirizzo from su di esso.

Cosa Hai Costruito

Email Credits, definito una volta e allegato sia al piano di abbonamento che al pacchetto ricarica. $19/mese concede 5,000 email per ciclo. I clienti sanno per cosa stanno pagando, tu conosci il tuo costo peggiore. Un prodotto una tantum che concede 5,000 email. Si somma ai crediti di abbonamento senza bisogno di modificare il piano. Una singola chiamata createLedgerEntry dopo ogni invio. Nessun contatore, nessun ritardo di aggregazione, idempotente al retry tramite l’id messaggio di Resend. Leggi la documentazione completa di CBB per rollover, modalità di sovraccarico, gestione del registro e l’intera superficie API. Hai bisogno di aiuto?

What You Built

One reusable credit unit

Email Credits, defined once and attached to both the subscription plan and the top-up pack.

Subscription with prepaid allowance

$19/month grants 5,000 emails per cycle. Customers know what they’re paying for, you know your worst-case cost.

Top-up pack

A one-time product that grants 5,000 emails. Stacks on subscription credits with no plan change required.

Instant ledger debits

A single createLedgerEntry call after each send. No meter, no aggregation lag, idempotent on retry via Resend’s message id.

Credit-Based Billing Reference

Read the full CBB documentation for rollover, overage modes, ledger management, and the complete API surface.
Need help?
Last modified on May 14, 2026