Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Guide for managing recurring subscriptions after checkout, including trials, lifecycle states, plan changes, cancellation, failed-payment recovery, proration, mandates, and on-demand charges.
.claude/skills/hashgraph-online-subscription-integration/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 76% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 35% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 70% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 115% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 45% | 0% |
Implement recurring billing with trials, plan changes, and on-demand charging. Subscriptions are created through Checkout Sessions, managed via the subscriptions API, and monitored through webhooks.
Subscription lifecycle: The six subscription statuses are pending, active, on_hold, cancelled, failed, and expired. A trialing subscription reports active; subscription.renewed is an event, not a status. Failed payments can move a subscription to on_hold (recoverable) or failed (terminal). Cancellation sets it to cancelled or schedules it to become expired at period end.
Checkout Sessions: The recommended path for creating subscriptions. A single-use hosted checkout that collects payment and customer data, then creates the subscription server-side.
Proration: When a customer changes plans mid-cycle, Dodo calculates credits or charges based on the time remaining. Proration mode controls whether the customer is billed immediately, credited, or neither.
Mandates: Authorization to charge a customer's payment method repeatedly (for subscriptions) or on-demand (for usage-based billing). Created during Checkout, can be updated if payment fails.
typescriptimport DodoPayments from 'dodopayments'; const client = new DodoPayments({ bearerToken: process.env.DODO_PAYMENTS_API_KEY, environment: 'test_mode', }); const session = await client.checkoutSessions.create({ product_cart: [ { product_id: 'pdt_monthly_plan', quantity: 1 } ], subscription_data: { trial_period_days: 14, // Optional }, customer: { email: 'subscriber@example.com', name: 'Jane Doe', }, return_url: 'https://yoursite.com/success', }); // Redirect user to session.checkout_url console.log('Redirect to:', session.checkout_url);
The customer completes payment on the hosted checkout. On success, Dodo creates the subscription and fires the subscription.active webhook.
typescriptconst session = await client.checkoutSessions.create({ product_cart: [ { product_id: 'pdt_pro_monthly', quantity: 1, addons: [ { addon_id: 'adn_extra_seats', quantity: 3 } ] } ], subscription_data: { trial_period_days: 7, }, customer: { email: 'user@example.com' }, return_url: 'https://yoursite.com/success', });
| Status | Meaning | Transitions | |--------|---------|-----------| | pending | Creation in progress | → active, failed | | active | Actively renewing | → on_hold, cancelled, expired | | on_hold | Renewal/plan-change payment failed; recoverable | → active (payment method updated), failed (retries exhausted), cancelled | | cancelled | Will not renew | → expired (at period end) | | failed | Initial mandate/payment failed; terminal | (no recovery) | | expired | Subscription term ended | (terminal) |
Trial period: If trial_period_days is set, the subscription enters active immediately but charges nothing until the trial ends. The first charge occurs on the trial end date.
typescriptconst subscription = await client.subscriptions.retrieve('sub_xxxxx'); console.log(subscription.status, subscription.next_billing_date);
When a subscription is on_hold due to failed payment, the customer can update their payment method. This automatically charges any outstanding dues.
typescriptawait client.subscriptions.updatePaymentMethod('sub_xxxxx', { payment_method: { type: 'existing', payment_method_id: 'pm_new_method', }, });
Success emits payment.succeeded followed by subscription.active.
typescriptconst history = await client.subscriptions.retrieveUsageHistory('sub_xxxxx', { page_size: 50, page_number: 0, });
typescriptconst creditUsage = await client.subscriptions.retrieveCreditUsage('sub_xxxxx'); console.log('Subscription:', creditUsage.subscription_id); for (const item of creditUsage.items) { console.log(item.credit_entitlement_name, item.balance); // balance is a string }
typescriptawait client.subscriptions.changePlan('sub_xxxxx', { product_id: 'pdt_higher_tier', quantity: 1, proration_billing_mode: 'prorated_immediately', on_payment_failure: 'prevent_change', });
Proration modes:
| Mode | Upgrade | Downgrade | Billing date | |------|---------|-----------|--------------| | prorated_immediately | Time-prorated charge | Time-prorated credit | Resets to change date | | difference_immediately | Full new-plan charge | Difference becomes credit | Resets | | full_immediately | Full new-plan charge | Full new-plan charge, no credit | Resets | | do_not_bill | No charge | No credit | Preserved |
Payment failure handling:
prevent_change: Keep the old plan if the charge fails.apply_change: Apply the new plan even if payment fails (subscription may become on_hold).Show the customer a quote before committing:
typescriptconst preview = await client.subscriptions.previewChangePlan('sub_xxxxx', { product_id: 'pdt_new_plan', quantity: 1, proration_billing_mode: 'prorated_immediately', }); console.log('Effective at:', preview.immediate_charge.effective_at); console.log('Line items:', preview.immediate_charge.line_items); console.log('Summary:', preview.immediate_charge.summary); console.log('New plan:', preview.new_plan);
typescriptawait client.subscriptions.cancelChangePlan('sub_xxxxx');
typescriptawait client.subscriptions.update('sub_xxxxx', { status: 'cancelled', });
Access is revoked immediately.
typescriptawait client.subscriptions.update('sub_xxxxx', { cancel_at_next_billing_date: true, });
The subscription remains active until the next billing date, then transitions to expired. The subscription.cancelled webhook includes cancel_at_next_billing_date: true and next_billing_date so you know when to revoke access.
For usage-based or metered subscriptions, charge the customer on-demand without a scheduled renewal.
typescriptconst session = await client.checkoutSessions.create({ product_cart: [ { product_id: 'pdt_usage_based', quantity: 1 } ], subscription_data: { on_demand: { mandate_only: true, } }, customer: { email: 'user@example.com' }, return_url: 'https://yoursite.com/success', });
This creates a subscription with a mandate but no automatic renewal. You control when to charge.
typescriptconst charge = await client.subscriptions.charge('sub_xxxxx', { product_price: 2500, // $25.00 in cents product_currency: 'USD', product_description: 'API calls for January 2025', }); console.log('Payment ID:', charge.payment_id);
Amounts are in the smallest currency unit (cents for USD, paise for INR, etc.).
Dodo does not automatically retry on-demand charges. You own retry logic and decline filtering. Monitor payment.failed webhooks and implement your own retry strategy.
When a renewal or plan-change charge fails, the subscription moves to on_hold. This is recoverable.
typescript// Listen for subscription.on_hold webhook case 'subscription.on_hold': // Notify customer, offer payment method update await sendPaymentFailedEmail(data.customer.customer_id); break;
subscription.active.updatePaymentMethod().Dunning sends up to four configurable emails for on_hold renewals and customer-portal cancellations. Exhausted dunning does not change the subscription state; you must handle the final outcome.
Allow customers to self-serve: view subscriptions, update payment methods, cancel, and upgrade/downgrade.
typescriptconst portalSession = await client.customers.customerPortal.create( 'cus_xxxxx', { return_url: 'https://yoursite.com/account' } ); // Redirect to portalSession.link
Portal links expire after 24 hours. Customers can:
on_hold subscriptionsFor full customer management (creating, updating, listing), see the customer-management skill.
| Event | When | Action | |-------|------|--------| | subscription.active | Subscription becomes active, including a trial start or recovery | Grant access | | subscription.renewed | Successful renewal | Log renewal, send receipt | | subscription.on_hold | Renewal/plan-change payment failed | Notify customer, offer recovery | | subscription.plan_changed | Plan upgraded/downgraded or add-ons changed | Update entitlements | | subscription.cancelled | Customer cancels | Schedule access revocation per cancel_at_next_billing_date | | subscription.failed | Initial mandate/payment failed | Notify customer, offer retry or new subscription | | subscription.expired | Subscription term ended | Revoke access |
Webhook signature verification, raw-body handling, durable processing, and idempotency are covered in the webhook-integration skill.
typescriptimport { NextRequest, NextResponse } from 'next/server'; export async function POST(req: NextRequest) { const raw = await req.text(); const headers = Object.fromEntries(req.headers.entries()); const event = await client.webhooks.unwrap(raw, { headers }); const webhookId = req.headers.get('webhook-id'); if (!webhookId) { return NextResponse.json({ error: 'Missing webhook-id' }, { status: 400 }); } // Implement this as an atomic insert backed by a UNIQUE constraint. // Keep the claim and entitlement changes in the same database transaction. const claimed = await claimWebhookId(webhookId); if (!claimed) { return NextResponse.json({ received: true }); } switch (event.type) { case 'subscription.active': await grantAccess(event.data.customer.customer_id, event.data.product_id); break; case 'subscription.on_hold': await notifyPaymentFailed(event.data.customer.customer_id); break; case 'subscription.cancelled': if (event.data.cancel_at_next_billing_date) { await scheduleAccessRevocation(event.data.subscription_id, new Date(event.data.next_billing_date)); } else { await revokeAccessImmediately(event.data.subscription_id); } break; case 'subscription.expired': await revokeAccess(event.data.customer.customer_id); break; } return NextResponse.json({ received: true }); }
Attach credit entitlements to subscription products to grant credits each billing cycle. For full credit management, see the credit-based-billing skill.
Quick example:
typescript// Product has credit entitlement attached (e.g., 10,000 tokens/month) const session = await client.checkoutSessions.create({ product_cart: [ { product_id: 'pdt_pro_with_credits', quantity: 1 } ], subscription_data: { trial_period_days: 14, }, customer: { email: 'user@example.com' }, return_url: 'https://yoursite.com/success', });
On each renewal, credits are issued. Monitor credit.added and credit.deducted webhooks to sync your ledger.
return_url Instead of WebhookWrong:
typescript// On return_url redirect await grantAccess(user.id);
The return_url is hit before the subscription is fully created. Dodo may still be processing the mandate or payment. Always wait for subscription.active webhook.
Right:
typescript// In webhook handler case 'subscription.active': await grantAccess(data.customer.customer_id); break;
subscriptions.createWrong:
typescriptconst sub = await client.subscriptions.create({ product_id: 'pdt_monthly', customer_id: 'cus_xxxxx', });
This endpoint is deprecated. Use Checkout Sessions.
Right:
typescriptconst session = await client.checkoutSessions.create({ product_cart: [{ product_id: 'pdt_monthly', quantity: 1 }], customer: { customer_id: 'cus_xxxxx' }, return_url: 'https://yoursite.com/success', });
subscription.plan_changedsubscription.plan_changed fires for upgrades, downgrades, add-on changes, AND when cancel_at_next_billing_date is toggled. Don't assume every plan_changed event means a paid upgrade succeeded. Inspect the payload and check the subscription's current product_id.
Wrong:
typescript// Missing proration_billing_mode await client.subscriptions.changePlan('sub_xxxxx', { product_id: 'pdt_new', quantity: 1, // proration_billing_mode: 'prorated_immediately', // REQUIRED });
This will fail. Always specify a proration mode.
on_hold with Pauseon_hold means payment failed, not that the customer paused. There is no general pause/resume operation. on_hold is recoverable only by updating the payment method or waiting for retry.
cancel_at_next_billing_datetypescript// Wrong: revoke immediately await revokeAccess(data.customer.customer_id); // Right: check the flag if (data.cancel_at_next_billing_date) { await scheduleAccessRevocation(data.subscription_id, new Date(data.next_billing_date)); } else { await revokeAccessImmediately(data.subscription_id); }
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 20,343 | 21,677 | +7% | 1 | 1 | 0% | 3,913 | 6,884 | +76% | 0 | 0 | — |
case-02 | fail→pass | 32,011 | 23,654 | -26% | 1 | 1 | 0% | 5,180 | 7,016 | +35% | 0 | 0 | — |
case-03 | fail→pass | 20,718 | 17,971 | -13% | 1 | 1 | 0% | 3,927 | 6,687 | +70% | 0 | 0 | — |
case-04 | pass→pass | 28,853 | 27,496 | -5% | 1 | 1 | 0% | 4,584 | 8,775 | +91% | 0 | 0 | — |
case-05 | fail→pass | 15,826 | 18,029 | +14% | 1 | 1 | 0% | 3,186 | 6,840 | +115% | 0 | 0 | — |
case-06 | pass→pass | 52,426 | 21,879 | -58% | 1 | 1 | 0% | 4,183 | 7,438 | +78% | 0 | 0 | — |
case-07 | fail→pass | 27,424 | 9,785 | -64% | 1 | 1 | 0% | 4,014 | 5,819 | +45% | 0 | 0 | — |
case-08 | fail→pass | 18,880 | 11,272 | -40% | 1 | 1 | 0% | 2,157 | 5,340 | +148% | 0 | 0 | — |
case-09 | fail→pass | 13,468 | 6,688 | -50% | 1 | 1 | 0% | 1,415 | 5,096 | +260% | 0 | 0 | — |
case-14 | fail→pass | 20,570 | 16,221 | -21% | 1 | 1 | 0% | 2,596 | 6,216 | +139% | 0 | 0 | — |
case-10 | fail→pass | 26,767 | 14,893 | -44% | 1 | 1 | 0% | 3,786 | 6,115 | +62% | 0 | 0 | — |
case-11 | fail→pass | 12,334 | 3,612 | -71% | 1 | 1 | 0% | 2,273 | 4,724 | +108% | 0 | 0 | — |
case-12 | fail→pass | 15,644 | 8,964 | -43% | 1 | 1 | 0% | 2,382 | 4,838 | +103% | 0 | 0 | — |
case-13 | fail→pass | 19,420 | 9,841 | -49% | 1 | 1 | 0% | 2,547 | 5,649 | +122% | 0 | 0 | — |
case-15 | pass→pass | 18,601 | 10,524 | -43% | 1 | 1 | 0% | 2,252 | 6,015 | +167% | 0 | 0 | — |
case-16 | fail→pass | 15,423 | 7,057 | -54% | 1 | 1 | 0% | 2,068 | 5,457 | +164% | 0 | 0 | — |
case-17 | fail→pass | 21,650 | 7,513 | -65% | 1 | 1 | 0% | 3,271 | 5,195 | +59% | 0 | 0 | — |
case-18 | fail→pass | 33,691 | 8,954 | -73% | 1 | 1 | 0% | 2,704 | 5,886 | +118% | 0 | 0 | — |
case-19 | pass→pass | 20,461 | 8,844 | -57% | 1 | 1 | 0% | 2,159 | 5,691 | +164% | 0 | 0 | — |
case-20 | pass→pass | 15,749 | 13,683 | -13% | 1 | 1 | 0% | 2,589 | 5,304 | +105% | 0 | 0 | — |
case-21 | pass→pass | 9,822 | 9,006 | -8% | 1 | 1 | 0% | 1,441 | 4,637 | +222% | 0 | 0 | — |
case-22 | fail→pass | 20,759 | 14,901 | -28% | 1 | 1 | 0% | 2,504 | 5,541 | +121% | 0 | 0 | — |
case-23 | fail→pass | 13,573 | 2,309 | -83% | 1 | 1 | 0% | 1,348 | 4,490 | +233% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +74 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.