Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Guide for issuing refunds, handling disputes and chargebacks, and reconciling customer access with Dodo Payments
.claude/skills/hashgraph-online-refunds-and-disputes/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-16 | ✗→✓ | ▲ Improved | 176% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 43% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 123% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 136% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 50% | 0% |
This skill covers issuing refunds (full and partial), handling the dispute lifecycle, and managing customer access during payment reversals. Disputes are inbound-only; Dodo handles the card-network process as Merchant of Record while you control application access and evidence gathering.
Refunds are initiated by you via the API. Each refund has a status that tells you whether the money has actually left your account. Partial refunds target specific line items in the original payment.
Disputes are initiated by the customer's card network. You cannot create them; you only list and retrieve them. The dispute lifecycle spans seven events, each requiring different application actions.
Amounts are always in the smallest currency unit (cents for USD, paise for INR, etc.).
Access revocation means removing the customer's ability to use the product or service. On a dispute, you typically revoke access while it's open. Restore it only on dispute.won; all other outcomes keep access revoked until you reconcile them separately.
typescriptimport DodoPayments from 'dodopayments'; const client = new DodoPayments({ bearerToken: process.env.DODO_PAYMENTS_API_KEY, environment: 'test_mode', }); // Full refund const refund = await client.refunds.create({ payment_id: 'pay_abc123', }); console.log(refund.refund_id); console.log(refund.status); // 'pending', 'succeeded', 'review', or 'failed'
To refund only specific items from a payment, pass the items array with each item ID and the amount to refund in the smallest currency unit. Omit amount to refund the whole item:
typescriptconst partialRefund = await client.refunds.create({ payment_id: 'pay_abc123', items: [ { item_id: 'item_1', amount: 1000 }, { item_id: 'item_2', amount: 2500 }, ], });
Each refund has one of four statuses:
| Status | Meaning | Your action | |---|---|---| | pending | Refund is processing; money hasn't left your account yet | Wait for a webhook or poll the refund status | | succeeded | Money has been returned to the customer | Revoke access if the product is non-refundable; update your records | | review | Refund is under review (rare) | Contact support; do not assume it will succeed | | failed | Refund failed; money remains in your account | Investigate the failure; consider retrying or contacting the customer |
typescript// List all refunds const refunds = await client.refunds.list(); // Retrieve a specific refund const refund = await client.refunds.retrieve('ref_xyz789'); console.log(refund.status);
Webhook signature verification is covered in the webhook-integration skill. Always verify the signature before processing.
typescriptimport DodoPayments from 'dodopayments'; import express from 'express'; const app = express(); app.use(express.raw({ type: 'application/json' })); // `environment` is a narrow union, but env vars are `string | undefined`. // Narrow explicitly rather than casting, and default to test mode so a missing // variable can never accidentally hit live. const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode'; const client = new DodoPayments({ bearerToken: process.env.DODO_PAYMENTS_API_KEY, environment, webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY, }); app.post('/webhook', async (req, res) => { try { const event = client.webhooks.unwrap(req.body.toString(), { 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, }, }); if (event.type === 'refund.succeeded') { const refund = event.data; // Refund succeeded; revoke access if needed await revokeCustomerAccess(refund.customer.customer_id); await updateRefundRecord(refund.refund_id, 'succeeded'); } if (event.type === 'refund.failed') { const refund = event.data; // Refund failed; keep access active, alert support await logRefundFailure(refund.refund_id, refund.reason); } res.json({ received: true }); } catch (error) { res.status(401).json({ error: 'Invalid signature' }); } });
Disputes are inbound-only. You cannot create them; you can only list and retrieve them.
typescript// List all disputes const disputes = await client.disputes.list(); // Retrieve a specific dispute const dispute = await client.disputes.retrieve('dis_abc123'); console.log(dispute.dispute_status); console.log(dispute.amount); // in smallest currency unit
A dispute moves through seven events. Each event requires a different action from your application:
| Event | Meaning | Your action | |---|---|---| | dispute.opened | Customer initiated a chargeback | Record the dispute; consider revoking access immediately; gather evidence from your logs | | dispute.challenged | You submitted evidence | Wait for the card network to review | | dispute.accepted | You accepted (conceded) the dispute; funds go to the cardholder | Keep access revoked; mark the dispute as accepted in your records | | dispute.cancelled | Customer or system cancelled the dispute | Keep access revoked; reconcile the payment separately | | dispute.expired | Dispute window closed without resolution | Treat as lost; keep access revoked | | dispute.won | You won the dispute | Funds are retained; restore access; update customer records | | dispute.lost | You lost the dispute | Funds returned to cardholder; keep access revoked; reconcile your records |
Webhook dispute payloads intentionally contain no customer field. Resolve the customer with an extra disputes.retrieve() call: the webhook's dispute_id identifies the dispute, and the returned GetDispute includes customer. Without this lookup, access-control handlers would receive undefined.
typescriptasync function resolveDisputeCustomerId(disputeId: string) { const dispute = await client.disputes.retrieve(disputeId); return dispute.customer.customer_id; } app.post('/webhook', async (req, res) => { try { const event = client.webhooks.unwrap(req.body.toString(), { 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, }, }); if (event.type === 'dispute.opened') { const dispute = event.data; // Record the dispute and revoke access await recordDispute(dispute.dispute_id, dispute.payment_id, dispute.amount); const customerId = await resolveDisputeCustomerId(dispute.dispute_id); await revokeCustomerAccess(customerId); // Gather evidence from your system and submit via dashboard // (no evidence-submission API exists; use the Dodo dashboard) } if (event.type === 'dispute.won') { const dispute = event.data; // Funds retained; restore normal state await markDisputeResolved(dispute.dispute_id, 'won'); const customerId = await resolveDisputeCustomerId(dispute.dispute_id); await restoreCustomerAccess(customerId); } if (event.type === 'dispute.lost') { const dispute = event.data; // Funds returned to cardholder; keep access revoked await markDisputeResolved(dispute.dispute_id, 'lost'); // Do NOT restore access } if (event.type === 'dispute.accepted') { const dispute = event.data; // Merchant conceded; funds go to the cardholder and access stays revoked await markDisputeResolved(dispute.dispute_id, 'accepted'); // Do NOT restore access } if (event.type === 'dispute.cancelled') { const dispute = event.data; // Cancellation is not a win; keep access revoked and reconcile separately await markDisputeResolved(dispute.dispute_id, 'cancelled'); } res.json({ received: true }); } catch (error) { res.status(401).json({ error: 'Invalid signature' }); } });
Dodo handles the card-network dispute process as Merchant of Record. You submit evidence through the Dodo dashboard, not via API. No evidence-submission API exists.
When a dispute opens, gather your evidence (order confirmation, delivery proof, customer communication, etc.) and upload it to the dashboard within the dispute window (typically 4 days). The card network reviews your evidence and makes a final decision.
A common pattern for managing access during disputes:
typescriptasync function handleDisputeLifecycle( dispute: Awaited<ReturnType<typeof client.disputes.retrieve>>, ) { const customerId = dispute.customer.customer_id; switch (dispute.dispute_status) { case 'dispute_opened': // Revoke access immediately await revokeCustomerAccess(customerId); break; case 'dispute_won': // You won; restore access await restoreCustomerAccess(customerId); break; case 'dispute_lost': // You lost; keep access revoked // (do nothing) break; case 'dispute_accepted': // You conceded; funds go to the cardholder and access stays revoked break; case 'dispute_cancelled': // Cancellation is not a win; reconcile separately and keep access revoked break; case 'dispute_expired': // Treat as lost; keep access revoked break; case 'dispute_challenged': // Evidence is under review; keep access revoked break; } }
When a refund succeeds, you must reconcile the customer's access. If the product is non-refundable (e.g., a digital download or subscription already used), revoke access. If it's refundable (e.g., a subscription not yet started), you may restore access or leave it revoked depending on your policy.
typescriptasync function reconcileRefund(refund) { if (refund.status !== 'succeeded') { return; // Not yet final } const payment = await client.payments.retrieve(refund.payment_id); const customer = payment.customer; // Revoke access for non-refundable products const includesNonRefundableProduct = payment.product_cart?.some(({ product_id }) => isNonRefundable(product_id), ); if (includesNonRefundableProduct) { await revokeCustomerAccess(customer.customer_id); } // Update your entitlement records await updateEntitlementRecord(customer.customer_id, { refund_id: refund.refund_id, refund_amount: refund.amount, refund_date: new Date(), }); }
Auto-refunding without revoking access. A refund webhook means money is leaving your account. If the product is non-refundable, revoke access immediately. Don't wait for the customer to ask.
Treating dispute.opened as final. A dispute is not lost until the card network says so. Keep access revoked while it's open, but don't delete customer data or close their account.
Ignoring partial refunds when computing entitlements. If a customer refunds only one item from a multi-item purchase, their entitlement to the other items remains valid. Track refunds by item, not just by payment.
Restoring access on any outcome except dispute.won. dispute.accepted means you conceded and the cardholder receives the funds. A cancelled dispute is also not a win. Keep access revoked and reconcile separately unless you receive dispute.won.
Submitting evidence after the dispute window closes. The card network typically gives you 4 days to respond. Set a calendar reminder and gather evidence immediately when a dispute opens.
Assuming Dodo will handle access revocation. Dodo handles the card-network process; you handle application access. Dodo won't revoke your customer's subscription or file access automatically.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-16 | fail→pass | 17,120 | 18,258 | +7% | 1 | 1 | 0% | 1,897 | 5,230 | +176% | 0 | 0 | — |
case-01 | pass→pass | 15,080 | 13,009 | -14% | 1 | 1 | 0% | 1,998 | 4,440 | +122% | 0 | 0 | — |
case-02 | fail→pass | 26,945 | 14,478 | -46% | 1 | 1 | 0% | 4,418 | 6,328 | +43% | 0 | 0 | — |
case-03 | fail→fail | 23,404 | 13,992 | -40% | 1 | 1 | 0% | 3,493 | 5,958 | +71% | 0 | 0 | — |
case-04 | fail→pass | 9,866 | 10,956 | +11% | 1 | 1 | 0% | 1,898 | 4,232 | +123% | 0 | 0 | — |
case-05 | fail→pass | 16,059 | 12,497 | -22% | 1 | 1 | 0% | 2,167 | 5,124 | +136% | 0 | 0 | — |
case-06 | fail→fail | 20,642 | 7,847 | -62% | 1 | 1 | 0% | 3,229 | 4,479 | +39% | 0 | 0 | — |
case-07 | pass→pass | 11,634 | 8,899 | -24% | 1 | 1 | 0% | 1,194 | 3,889 | +226% | 0 | 0 | — |
case-08 | pass→pass | 6,397 | 12,585 | +97% | 1 | 1 | 0% | 1,143 | 4,709 | +312% | 0 | 0 | — |
case-09 | pass→pass | 17,665 | 15,607 | -12% | 1 | 1 | 0% | 2,311 | 5,417 | +134% | 0 | 0 | — |
case-10 | fail→pass | 21,897 | 14,346 | -34% | 1 | 1 | 0% | 3,377 | 5,079 | +50% | 0 | 0 | — |
case-11 | pass→pass | 24,818 | 14,854 | -40% | 1 | 1 | 0% | 3,909 | 5,142 | +32% | 0 | 0 | — |
case-12 | pass→pass | 26,655 | 20,006 | -25% | 1 | 1 | 0% | 3,149 | 5,609 | +78% | 0 | 0 | — |
case-13 | pass→pass | 17,640 | 14,436 | -18% | 1 | 1 | 0% | 3,459 | 5,014 | +45% | 0 | 0 | — |
case-14 | pass→pass | 22,466 | 16,381 | -27% | 1 | 1 | 0% | 2,687 | 5,240 | +95% | 0 | 0 | — |
case-15 | pass→pass | 10,819 | 11,224 | +4% | 1 | 1 | 0% | 1,640 | 4,434 | +170% | 0 | 0 | — |
case-17 | fail→pass | 18,594 | 6,287 | -66% | 1 | 1 | 0% | 2,023 | 4,178 | +107% | 0 | 0 | — |
case-18 | pass→fail | 12,239 | 4,466 | -64% | 1 | 1 | 0% | 1,452 | 4,117 | +184% | 0 | 0 | — |
case-19 | pass→pass | 20,302 | 19,308 | -5% | 1 | 1 | 0% | 2,975 | 5,555 | +87% | 0 | 0 | — |
case-20 | fail→pass | 17,634 | 13,364 | -24% | 1 | 1 | 0% | 2,777 | 4,478 | +61% | 0 | 0 | — |
case-21 | pass→pass | 10,127 | 9,066 | -10% | 1 | 1 | 0% | 1,738 | 4,092 | +135% | 0 | 0 | — |
case-22 | fail→pass | 15,922 | 4,898 | -69% | 1 | 1 | 0% | 2,293 | 4,274 | +86% | 0 | 0 | — |
case-23 | fail→pass | 8,111 | 5,772 | -29% | 1 | 1 | 0% | 1,233 | 4,063 | +230% | 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 +35 percentage points is the difference between those two pass rates over the 23 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
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.