refunds-and-disputes について
DodoPayments の開発者向けに、返金、異議申し立て、決済取り消し後のアクセス管理を扱うスキルです。API 操作、Webhook 処理、ライフサイクルの判断、利用権の照合を説明します。
主な機能
- 01全額・商品単位の返金作成
- 02返金と異議の一覧・取得
- 03返金・異議ステータスの解釈
- 04取り消し関連Webhookの処理
- 05顧客アクセスと利用権の照合
- 06異議の証拠収集と提出
活用シーン
- 01購入商品の一部だけを返金
- 02異議の係争中にアクセスを停止
- 03異議の勝訴・取り下げ後にアクセスを復元
- 04返金成功後に顧客の利用権を更新
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dodopayments/ skills --skill refunds-and-disputesインストール後、Claude Code で/refunds-and-disputesを入力して読み込まれたか確認します。
詳細
返金と異議申し立て
このスキルでは、返金(全額および一部)の実行、異議申し立てのライフサイクルへの対応、支払いの取り消し中における顧客のアクセス管理について説明します。異議申し立ては受信のみです。Dodo が販売者としてカードネットワークの手続きを担当し、アプリケーションへのアクセスと証拠の収集はお客様が管理します。
このスキルを使う場面
- 支払いに対して全額または一部の返金を実行する必要がある
- 顧客が請求に異議を申し立て、証拠を添えて回答する必要がある
- 返金または異議申し立てのステータスに連動するアクセス取り消しのロジックを構築している
- 支払いの取り消し後に顧客の利用権を調整する必要がある
- 本番環境で返金または異議申し立ての Webhook を処理している
基本概念
返金は、API を通じてお客様が開始します。各返金にはステータスがあり、実際にお客様のアカウントから資金が出たかどうかを示します。一部返金では、元の支払いに含まれる特定の明細項目を対象にします。
異議申し立ては、顧客のカードネットワークによって開始されます。お客様が作成することはできず、一覧表示と取得のみ可能です。異議申し立てのライフサイクルには 7 つのイベントがあり、それぞれアプリケーションで異なる対応が必要です。
金額は常に通貨の最小単位で指定します(USD ではセント、INR ではパイサなど)。
アクセスの取り消しとは、顧客が製品やサービスを利用できないようにすることです。異議申し立て中は、通常、アクセスを取り消します。dispute.won または dispute.cancelled(取り下げられた状態で、以降の対応は不要)ではアクセスを復元します。敗訴となる結果(dispute.accepted、dispute.lost、dispute.expired)では、アクセスを取り消したままにします。
返金
返金を作成する
import 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'
明細項目ごとの一部返金
支払いに含まれる特定の項目のみを返金するには、各項目 ID と返金額を通貨の最小単位で指定した items 配列を渡します。項目全体を返金する場合は amount を省略します。
const partialRefund = await client.refunds.create({
payment_id: 'pay_abc123',
items: [
{ item_id: 'item_1' }, // no amount: the whole item is refunded
{ item_id: 'item_2', amount: 2500 },
],
});
返金のステータス
各返金には、次の 4 つのステータスがあります。
| ステータス | 意味 | 対応 |
|---|---|---|
pending | 返金を処理中です。まだお客様のアカウントから資金は出ていません | Webhook を待つか、返金ステータスをポーリングします |
succeeded | 顧客に資金が返金されました | 返金不可の製品の場合はアクセスを取り消し、記録を更新します |
review | 返金は審査中です(まれなケース) | サポートに問い合わせ、返金が成功すると決めつけないでください |
failed | 返金に失敗しました。資金はお客様のアカウントに残っています | 失敗の原因を調査し、再試行するか顧客に問い合わせることを検討します |
返金の一覧表示と取得
// 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 への対応
Webhook の署名検証については、webhook-integration スキルで説明しています。処理する前に、必ず署名を検証してください。
import 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,
});
type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
// Register ONE endpoint for every Dodo event. A second app.post('/webhook')
// would never run, because the first handler answers every request.
app.post('/webhook', async (req, res) => {
let event: WebhookEvent;
try {
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,
},
});
} catch {
// Only signature verification failures reach this branch
res.status(401).json({ error: 'Invalid signature' });
return;
}
try {
await handleRefundEvent(event);
await handleDisputeEvent(event); // defined under "Handle dispute webhooks"
res.json({ received: true });
} catch (error) {
// Any non-2xx response makes Dodo retry the delivery
console.error('Webhook handler failed:', error);
res.status(500).json({ error: 'Webhook handler failed' });
}
});
async function handleRefundEvent(event: WebhookEvent) {
if (event.type === 'refund.succeeded') {
const refund = event.data;
if (!refund.is_partial) {
// Full refund: nothing in the payment is still paid for
await revokeCustomerAccess(refund.customer.customer_id);
} else {
// Partial refund: keep access to items that are still paid for, and
// remove it for any item with nothing left to refund. Reading the line
// items covers every case - an omitted amount, an explicit amount equal
// to the item price, or several partial refunds that add up to it.
const { items } = await client.payments.retrieveLineItems(refund.payment_id);
const fullyRefundedItemIds = items
.filter((item) => item.refundable_amount === 0)
.map((item) => item.items_id);
await revokeItemAccess(refund.customer.customer_id, fullyRefundedItemIds);
}
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);
}
}
異議申し立て
異議申し立ての取得
異議申し立ては受信のみです。お客様が作成することはできず、一覧表示と取得のみ可能です。
// 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
異議申し立てのライフサイクル
異議申し立てには 7 つのイベントがあります。各イベントに対して、アプリケーションで異なる対応が必要です。
| イベント | 意味 | 対応 |
|---|---|---|
dispute.opened | 顧客がチャージバックを開始しました | 異議申し立てを記録し、直ちにアクセスを取り消すことを検討して、ログから証拠を収集します |
dispute.challenged | お客様が証拠を提出しました | カードネットワークによる審査を待ちます |
dispute.accepted | お客様が異議申し立てを受け入れました(認めました)。資金はカード会員に渡ります | アクセスを取り消したままにし、記録上で異議申し立てを受け入れたものとしてマークします |
dispute.cancelled | 異議申し立てが取り下げられたか、キャンセルされました | 以降の対応は不要です。dispute.opened 時に取り消したアクセスを復元します |
dispute.expired | 解決されないまま異議申し立ての期限が終了しました | 敗訴として扱い、アクセスを取り消したままにします |
dispute.won | お客様が異議申し立てに勝訴しました | 資金は保持されます。アクセスを復元し、顧客の記録を更新します |
dispute.lost | お客様が異議申し立てに敗訴しました | 資金がカード会員に返金されます。アクセスを取り消したままにし、記録を調整します |
異議申し立ての Webhook を処理する
Webhook の異議申し立てペイロードには、意図的に顧客フィールドが含まれていません。追加で disputes.retrieve() を呼び出して顧客を特定してください。Webhook の dispute_id は異議申し立てを識別し、返される GetDispute には customer が含まれます。この検索を行わないと、アクセス制御ハンドラーには undefined が渡されます。
async function resolveDisputeCustomerId(disputeId: string) {
const dispute = await client.disputes.retrieve(disputeId);
return dispute.customer.customer_id;
}
// Called from the single /webhook handler above, after signature verification
async function handleDisputeEvent(event: WebhookEvent) {
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;
// The dispute was withdrawn; no further action is needed, so undo the
// revocation from dispute.opened
await markDisputeResolved(dispute.dispute_id, 'cancelled');
const customerId = await resolveDisputeCustomerId(dispute.dispute_id);
await restoreCustomerAccess(customerId);
}
}
証拠の提出
Dodo は Merchant of Record として、カードネットワークの異議申し立てプロセスを処理します。証拠は API 経由ではなく、Dodo ダッシュボードから提出します。証拠提出 API はありません。
異議申し立てが開始されたら、証拠(注文確認、配送証明、顧客とのやり取りなど)を集め、異議申し立て期間内(異議申し立ての作成から 10 日以内)にダッシュボードへアップロードしてください。カードネットワークが証拠を審査し、最終決定を下します。
アクセスを取り消すパターン
異議申し立て中のアクセスを管理する一般的なパターンは次のとおりです。
async 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':
// Withdrawn; no further action needed, so restore access
await restoreCustomerAccess(customerId);
break;
case 'dispute_expired':
// Treat as lost; keep access revoked
break;
case 'dispute_challenged':
// Evidence is under review; keep access revoked
break;
}
}
返金後に利用権を調整する
返金が成功したら、顧客のアクセスを調整する必要があります。返金不可の商品(デジタルダウンロードや、すでに利用されたサブスクリプションなど)の場合は、アクセスを取り消します。返金可能な商品(まだ開始されていないサブスクリプションなど)の場合は、ポリシーに応じてアクセスを復元するか、取り消したままにすることができます。
async 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(),
});
}
よくある間違い
アクセスを取り消さずに自動返金する。 返金の Webhook は、アカウントから資金が出ていくことを意味します。商品が返金不可の場合は、直ちにアクセスを取り消してください。顧客からの問い合わせを待ってはいけません。
dispute.opened を最終結果として扱う。 カードネットワークから結果が通知されるまでは、異議申し立てに敗れたことにはなりません。異議申し立て中はアクセスを取り消したままにしますが、顧客データを削除したり、アカウントを閉鎖したりしないでください。
利用権の計算時に一部返金を無視する。 顧客が複数商品の購入から 1 商品だけを返金した場合、残りの商品に対する利用権は引き続き有効です。支払い単位だけでなく、商品ごとに返金を追跡してください。
敗訴した結果でアクセスを復元する。 dispute.accepted、dispute.lost、dispute.expired は、カード会員に有利な形で解決(または通常は解決)されます。アクセスは取り消したままにしてください。アクセスを復元するのは dispute.won または dispute.cancelled の場合のみです(異議申し立てが取り下げられ、追加の対応が不要になった場合)。
異議申し立て期間の終了後に証拠を提出する。 異議申し立ての作成から回答までに 10 日あります。カレンダーにリマインダーを設定し、異議申し立てが開始されたらすぐに証拠を集めてください。
Dodo がアクセスの取り消しを処理すると想定する。 Dodo が処理するのはカードネットワークの手続きです。アプリケーションへのアクセス管理はお客様が行います。Dodo が顧客のサブスクリプションやファイルへのアクセスを自動的に取り消すことはありません。
リソース
- 返金 API
- 返金機能ガイド
- 異議申し立ての Webhook
- 署名検証に関する
webhook-integrationスキル