Handle a dispute
React to chargebacks, attach evidence, and reconcile outcomes.
Goal
Build the operational glue around chargebacks: fire an alert when a Dispute opens, surface it in your internal tooling alongside the original Order, and update your records when the network rules. Most evidence collection happens through the dashboard; the SDK's role is detection, correlation, and bookkeeping.
Prerequisites
- Easy Labs API key — see Quickstart.
@easylabs/nodeinstalled.- A registered webhook endpoint that subscribes to
dispute.createdanddispute.updated. Register one withclient.registerWebhookEndpoint({ url, events: ["dispute.created", "dispute.updated"] })and store the returned signing secret immediately — it is only ever returned once.
Implementation
1. Listen for new disputes
import { createClient, EasyWebhooks } from "@easylabs/node";
const easy = await createClient({ apiKey: process.env.EASY_API_KEY! });
app.post("/webhooks/easy", async (req, res) => {
const event = EasyWebhooks.constructEvent(
req.rawBody,
req.header("x-easy-webhook-signature") ?? "",
process.env.EASY_WEBHOOK_SECRET!,
);
if (event.type === "dispute.created") {
const dispute = event.data as { id: string; transfer: string };
await alertOpsTeam({
disputeId: dispute.id,
transferId: dispute.transfer,
});
}
if (event.type === "dispute.updated") {
const dispute = event.data as { id: string; status: string };
await updateInternalRecord(dispute.id, dispute.status);
}
res.status(204).end();
});EasyWebhooks.constructEvent verifies the HMAC-SHA256 signature against the raw request body. Do not parse the body before passing it in — JSON re-stringifying changes whitespace and breaks the signature check.
2. Correlate the Dispute back to your Order
The Dispute's transfer field is the Transfer ID; from there you can resolve the Order:
const { data: dispute } = await easy.getDispute(disputeId);
const transferId = dispute.transfer as string;
const { data: transfer } = await easy.getTransfer(transferId);
// In production, look up your internal order using transfer.tags.internal_order_id
// or by querying your DB for the matching Customer + amount + timestamp.If you set tags on the Transfer or Order at creation time (e.g. tags: { internal_order_id }), this lookup is a single DB query.
3. Tag the Dispute for internal triage
You can attach your own metadata to the Dispute so it correlates with your internal ticketing system:
await easy.updateDispute(disputeId, {
internal_case_id: "CASE-2026-00123",
assigned_to: "ops-team",
});tags are the only mutable surface on a Dispute — submitting evidence happens through the dashboard.
4. Resolve
When dispute.updated fires with a terminal status (WON, LOST, ACCEPTED), update your books:
WON— funds return to the merchant settlement; reverse any provisional refund you issued.LOST— funds remain with the buyer; write off the loss against the original Order.ACCEPTED— you chose not to contest; same financial impact asLOST.
Tradeoffs
- The SDK does not currently expose programmatic evidence upload — that workflow lives in the dashboard. If you need API-driven evidence for a custom internal tool, talk to your account team.
- The Dispute schema is processor-permissive (
{ id: string; [key: string]: unknown }) — fields beyondid,amount,currency,status,transfer,reason_code, andrespond_bymay vary across networks. Treat unknown fields defensively. - Always rely on
dispute.updatedfor terminal status. PollinggetDisputeworks but wastes API budget.