back

payments-with-dodo

Implement or repair Dodo Payments checkout, subscriptions, webhooks, entitlements, billing UI, refunds, and verification. Use when adding Dodo billing or auditing its full lifecycle.

Category
payments
Package
payments-with-dodo/SKILL.md
License
MIT
Author
@tushaarmehtaa
Tags
dodo-paymentscheckoutsubscriptionswebhooksentitlementsbilling

Install

Swipe for more runtimes.

Codex

Skills directory: ~/.codex/skills

available to install

Install globally

$npx skills add tushaarmehtaa/tushar-skills --skill payments-with-dodo -g -a codex -y

Invoke

$payments-with-dodo or /skills

You can also describe the task naturally; runtimes may select the skill from its description.

Required access

project filesterminal commandsnetwork access

local coding agent required

This skill requires project files, terminal commands, and network access. Uploading it to a chat app does not provide equivalent execution.

ChatGPT Skills

This workflow needs a local coding environment or capabilities that a chat-only Skills upload does not provide.

Why local agent required →

Instructions

Source: SKILL.md

Payments with Dodo

Implement the full billing lifecycle. Dodo is the source of billing events; the application’s server-side entitlement model is the authorization boundary.

Workflow

  1. Inspect framework/runtime, auth, database, existing provider/SDK and version, products, billing code, webhook storage, environment mapping, and deployment targets. Do not install Dodo beside another provider without explicit migration scope.
  2. Infer existing products and commercial policy. Ask only about unresolved one-time/subscription behavior, currency, trials, cancellation timing, refunds, tax, and capabilities unlocked.
  3. Model server-owned products, prices, and entitlements. The browser may submit a stable product key, never price, currency, credit amount, or entitlement.
  4. Keep test/live API keys, product IDs, webhook keys, and environment settings separate. Document names in .env.example without values.
  5. Read pricing and checkout for current official SDK checkout, product mapping, portal, and UI patterns. Read implementation guidance only when designing pricing or feature gates.
  6. Read webhooks before implementing event ingestion. Prefer official SDK verification, a durable webhook inbox with unique event ID, transactional state updates, explicit transient/permanent failure policy, and ordering by provider timestamps/version where available.
  7. Store provider customer/payment/subscription IDs and normalized status, billing-period boundaries, cancellation schedule, and last processed event data needed for reconciliation. Handle relevant payment, subscription, refund, dispute, dunning, and entitlement events.
  8. Protect paid features on the server using normalized entitlements. UI gates explain access but do not grant it.
  9. Build billing UI for checkout states, current entitlement, renewal/cancellation timing, failed-payment recovery, invoices/portal, and delayed webhook confirmation. Never treat a return URL or query parameter as proof of payment.

Verification

In Dodo test mode, test purchase, invalid product key, duplicate/concurrent webhook, invalid signature, transient database failure and retry, out-of-order update, renewal, scheduled cancellation, expiration, plan change, failed payment/on-hold/recovery, refund, dispute where relevant, replay, and unknown customer. Reconcile a stored subscription against the provider API. Run repository lint/type/test/build commands.

Output

Report product/entitlement mapping, SDK and environment detected, files/migrations changed, checkout/portal paths, webhook inbox and state transitions, payload minimization/access/retention decisions, server enforcement points, test evidence/event IDs, and exact dashboard/production steps still required.

Bundled references

3 files · 321 lines

references/implementation-guide.md

source ↗

Pricing and entitlement design

Read this reference only when the task includes pricing architecture, tier presentation, or feature gates. Commercial choices require product evidence; do not manufacture tiers or urgency.

Contents

Define the commercial model

Infer existing plans, prices, trials, limits, and product IDs from server config and Dodo dashboard mappings. Ask only about unresolved choices. Record:

  • one-time, subscription, credit, seat, or usage model;
  • currency and tax-inclusive/exclusive display;
  • monthly/annual periods and truthful annual savings;
  • trial start/end and payment-method requirements;
  • upgrade/downgrade proration and effective timing;
  • cancellation/refund policy;
  • exact capabilities and limits granted.

Do not add a decoy tier, “most popular” badge, urgency, or savings claim without evidence. Do not gate core data access or cancellation behind a higher plan.

Model entitlements

Define capabilities and quantitative limits independently of display names:

export type Entitlement =
  | 'export:data'
  | 'api:access'
  | 'team:manage';

export type BillingState = {
  status: 'free' | 'trialing' | 'active' | 'on_hold' | 'cancel_scheduled' | 'expired';
  entitlements: ReadonlySet<Entitlement>;
  currentPeriodEnd?: Date;
};

Server checks resolve the latest normalized billing state. Avoid storing only users.plan = 'pro'; that cannot represent scheduled cancellation, period boundaries, add-ons, grandfathering, or recovery.

export async function requireEntitlement(userId: string, entitlement: Entitlement) {
  const state = await getBillingState(userId);
  if (!state.entitlements.has(entitlement)) {
    throw new BillingAccessError(entitlement);
  }
}

Use usage reservations/counters for quantitative limits rather than a simple Boolean gate.

Present pricing

Pricing UI must derive from the same server-owned catalog used for checkout. Show:

  • exact billing period/currency and tax wording;
  • trial and renewal terms;
  • meaningful feature/limit differences;
  • current plan, scheduled changes, and effective dates;
  • accessible comparison and CTA states;
  • a clear manage/cancel/refund path.

The return/success page displays “confirming” until server billing state reflects a verified provider event or a server-side provider lookup. Never display a query-string plan as proof of upgrade.

Experiment safely

Define a hypothesis, primary conversion metric, guardrails (refunds, support, churn), assignment unit, sample-size plan, and stopping rule before an experiment. Run only one materially interacting pricing experiment at a time and preserve tax/renewal disclosure in every variant.

Verification

  • Catalog, UI display, checkout mapping, and server entitlements agree.
  • Unknown products/features deny safely.
  • Free, trial, active, on-hold, scheduled-cancel, expired, and grandfathered states render correctly.
  • Upgrade/downgrade/refund timing matches provider behavior and policy.
  • Accessibility, currency/tax, and renewal disclosures are reviewed.

references/pricing-and-checkout.md

source ↗

Dodo pricing, checkout, and portal

Read this reference after detecting the official SDK version and existing provider. Current primary documentation uses the dodopayments package and Checkout Sessions API; verify names against the installed version.

Contents

Catalog and environment

Create a typed, server-only mapping. The browser submits only a catalog key.

export const BILLING_PRODUCTS = {
  proMonthly: {
    productIdEnv: 'DODO_PRO_MONTHLY_PRODUCT_ID',
    entitlementSet: 'pro',
    mode: 'subscription',
  },
  credits100: {
    productIdEnv: 'DODO_CREDITS_100_PRODUCT_ID',
    entitlementSet: 'credits_100',
    mode: 'one_time',
  },
} as const;

Keep explicit test/live mappings and initialize the client with the detected environment:

import DodoPayments from 'dodopayments';

export const dodo = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
  environment: process.env.DODO_PAYMENTS_ENVIRONMENT as 'test_mode' | 'live_mode',
  webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});

Use environment values supported by the installed SDK. Do not expose API/webhook keys.

Checkout session

import { dodo } from '@/lib/dodo';
import { BILLING_PRODUCTS } from '@/config/billing';

export async function POST(req: Request) {
  const user = await requireUser(req);
  const { productKey } = await validateCheckoutBody(req);
  const product = BILLING_PRODUCTS[productKey];
  if (!product) return Response.json({ error: 'Unknown product' }, { status: 400 });

  const productId = process.env[product.productIdEnv];
  if (!productId) throw new Error(`Missing ${product.productIdEnv}`);

  const session = await dodo.checkoutSessions.create({
    product_cart: [{ product_id: productId, quantity: 1 }],
    customer: { email: user.email, name: user.name },
    metadata: {
      accountId: user.accountId,
      userId: user.id,
      productKey,
    },
    return_url: `${process.env.APP_URL}/billing/return`,
  });

  return Response.json({ checkoutUrl: session.checkout_url });
}

Validate authenticated customer fields, allowed return origin, existing subscription/duplicate checkout policy, and product availability. Do not accept amount, price, credit count, or entitlement from the browser.

Customer portal

Current official SDK patterns create a portal session from the stored Dodo customer ID:

export async function POST(req: Request) {
  const user = await requireUser(req);
  if (!user.dodoCustomerId) {
    return Response.json({ error: 'No billing customer' }, { status: 404 });
  }

  const session = await dodo.customers.customerPortal.create(user.dodoCustomerId, {
    return_url: `${process.env.APP_URL}/settings/billing`,
  });
  return Response.json({ portalUrl: session.link });
}

Adapt optional argument shape to the installed SDK version. Authorize the stored customer ID; never accept it from the browser.

Success and recovery UI

The return route is not payment proof. Show a pending state and poll a server billing-status endpoint with a bounded backoff, or verify the checkout/session server-side when supported. Stop on active/final failure/timeout and provide recovery guidance.

Show current period end, scheduled cancellation, on-hold/payment-update action, invoice/portal access, and support path. Render from normalized server billing state, not URL parameters.

Verification

  • Unknown/tampered product keys are rejected.
  • Test/live IDs and keys cannot mix.
  • Authenticated checkout metadata maps to the correct account and server catalog.
  • Return UI remains pending until verified state changes and times out cleanly.
  • Portal denies users without the stored customer and never accepts another customer ID.
  • Server entitlement checks pass/deny for all billing states.

references/webhooks.md

source ↗

Dodo webhook ingestion

Read this reference before implementing Dodo event handling. Prefer the installed official dodopayments SDK’s webhooks.unwrap() over manual Standard Webhooks logic. Verify current event names/payloads against Dodo primary documentation.

Contents

Delivery contract

Dodo signs the raw request and retries failed deliveries. Its documentation recommends fast acknowledgement when processing asynchronously. That is safe only after the event is durably stored/enqueued. A synchronous handler should return non-2xx on transient database failure so retry remains possible.

Do not parse then reserialize the body, manually strip/decode secrets when using the official helper, or swallow an unreachable backend with 200 before durable ingestion.

Verify and ingest

import { dodo } from '@/lib/dodo';

export async function POST(req: Request) {
  const payload = await req.text();
  let event;
  try {
    event = dodo.webhooks.unwrap(payload, {
      headers: {
        'webhook-id': req.headers.get('webhook-id')!,
        'webhook-signature': req.headers.get('webhook-signature')!,
        'webhook-timestamp': req.headers.get('webhook-timestamp')!,
      },
    });
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  await insertWebhookInbox({
    provider: 'dodo',
    eventId: req.headers.get('webhook-id')!,
    eventType: event.type,
    payload: event,
  });

  await enqueueWebhookProcessing(req.headers.get('webhook-id')!);
  return new Response('Accepted', { status: 200 });
}

insertWebhookInbox uses a unique (provider, event_id) constraint and returns the prior record on replay. If no durable queue exists, process in the same request and return failure when the transaction fails.

Payload privacy and retention

Store only the provider fields required to replay or reconcile the business transition. If the implementation keeps the signed raw body or full decoded payload, treat it as sensitive operational data: encrypt it at rest where the platform supports field-level protection, restrict access to the billing worker and audited operators, scrub it from logs and error trackers, and define a short retention/deletion policy after successful processing. Keep a minimized diagnostic projection—event ID/type, provider object IDs, environment, timestamps, status, and safe error code—for longer-lived operations.

Check current Dodo event schemas for email, address, tax, payment, or other personal fields before selecting the stored projection. Never copy secrets, full payment details, or unneeded customer metadata into the inbox. Document any provider or legal retention requirement separately from the application default.

Transactional processing

create table billing_webhook_inbox (
  provider text not null,
  event_id text not null,
  event_type text not null,
  payload jsonb not null,
  provider_created_at timestamptz,
  status text not null default 'pending',
  attempts integer not null default 0,
  last_error_code text,
  received_at timestamptz not null default now(),
  processed_at timestamptz,
  primary key (provider, event_id)
);

In one database transaction:

  1. lock/claim the pending inbox row;
  2. resolve account from stored provider IDs and stable server-created metadata;
  3. validate environment, product, currency/amount where relevant;
  4. update normalized payment/subscription/entitlement rows;
  5. write an audit transition/outbox notification;
  6. mark the inbox row processed.

The unique inbox insert and business update must make concurrent workers/replays harmless. A “check then update then mark” sequence without a transaction races.

Event state machine

Handle only events relevant to the commercial model, but cover their complete lifecycle. Current Dodo documentation includes:

  • payment success/failure/processing/cancellation;
  • subscription active/updated/on-hold/renewed/plan-changed/cancelled/failed/expired;
  • refund success/failure;
  • dispute opened/won/lost and other dispute transitions;
  • dunning/recovery, entitlement-grant, and credit events where used.

Map provider status to normalized billing state. Store cancel_at_next_billing_date, current period/end or expiry, scheduled changes, and provider IDs. Do not reduce every subscription event to users.plan = 'pro' | 'free'.

For one-time credits, resolve the credit amount from the immutable server catalog/product mapping; never default missing metadata to 100 credits.

Retries and ordering

Classify failures:

  • permanent invalid/unmapped events: mark needs_review with safe diagnostics and alert;
  • transient database/network failures: retry with backoff and dead-letter monitoring;
  • unknown event types: store and acknowledge, then alert only when relevant.

Use provider creation/update timestamps or fetched current resource state to prevent an older delivery from overwriting newer state. Periodic reconciliation should compare active local subscriptions with Dodo API state.

Local testing

Use Dodo test mode and current Dodo CLI webhook listener for signed live test events. Offline triggered payloads may be unsigned; use unsafe parsing only in an isolated test harness, never a production-reachable bypass.

Verification

  • Valid and invalid signatures behave correctly.
  • Duplicate and concurrent deliveries apply one business transition.
  • A forced database/queue failure remains retryable and is not lost behind 200.
  • Older events cannot overwrite newer subscription state.
  • Purchase, renewal, scheduled cancellation, expiration, on-hold/recovery, plan change, refund, and unknown-customer paths match policy.
  • Reconciliation detects and repairs a deliberately drifted fixture.
  • Stored payload fields, access controls, log redaction, encryption choice, and retention/deletion behavior match the documented privacy policy.