Easy Labs
PaymentsGuides

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/node installed.
  • A registered webhook endpoint that subscribes to dispute.created and dispute.updated. Register one with client.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 as LOST.

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 beyond id, amount, currency, status, transfer, reason_code, and respond_by may vary across networks. Treat unknown fields defensively.
  • Always rely on dispute.updated for terminal status. Polling getDispute works but wastes API budget.

On this page