Easy Labs
SDKsNode.jsExamples

Refunds

Refunds pattern for Node.js.

Refunding orders end-to-end with @easylabs/node — covering full and partial refunds, automatic accept-on-dispute, and tying the reversal back to your local ledger.

Goal

A reusable refund service that:

  1. Resolves the originating transfer from an order, invoice, or charge.
  2. Applies a partial or full reversal.
  3. Persists the reversal ID against your domain object for accounting.
  4. Reacts to dispute.created webhooks by pre-emptively refunding low-value disputes.

Implementation

1. Refund service

import { createClient } from "@easylabs/node";
// EasyApiError ships from @easylabs/common; @easylabs/node re-exports the
// *type* but not the runtime value, so `instanceof` needs the common import.
import { EasyApiError } from "@easylabs/common";
const easy = await createClient({ apiKey: process.env.EASY_API_KEY! });

type RefundReason = "customer_request" | "duplicate" | "fraud" | "shipping_delay" | "dispute_received";

export async function refundOrder(
  orderId: string,
  amountCents: number | "full",
  reason: RefundReason,
  approvedBy: string,
) {
  const { data: order } = await easy.getOrder(orderId);
  if (!order.transfer) throw new Error("Order has no captured transfer.");
  if (order.transfer.state !== "SUCCEEDED") {
    throw new Error(`Cannot refund transfer in state ${order.transfer.state}.`);
  }

  const refund_amount = amountCents === "full" ? order.transfer.amount : amountCents;

  try {
    const reversal = await easy.createRefund(order.transfer.id, {
      refund_amount,
      tags: {
        reason,
        approved_by: approvedBy,
        order_number: order.order_number,
      },
    });

    // Persist reversal.data.id against the order in your DB
    return reversal.data;
  } catch (err) {
    if (err instanceof EasyApiError && err.status === 422) {
      // Probably trying to refund more than was captured
      throw new Error(`Refund rejected: ${err.message}`);
    }
    throw err;
  }
}

2. Auto-refund small disputes

import { EasyWebhooks } from "@easylabs/node";

export async function handleWebhook(rawBody: string, sigHeader: string) {
  const event = EasyWebhooks.constructEvent(
    rawBody,
    sigHeader,
    process.env.EASY_WEBHOOK_SECRET!,
  );

  if (event.type === "dispute.created") {
    const dispute = event.data as { id: string; amount: number; transfer?: { id: string } };
    if (dispute.amount < 1000 && dispute.transfer) {
      // Cheaper to refund than to fight
      await easy.createRefund(dispute.transfer.id, {
        refund_amount: dispute.amount,
        tags: { reason: "dispute_received", dispute_id: dispute.id },
      });
      await easy.acceptDispute(dispute.id);
    }
  }
}

3. Reconcile a reversal back to your ledger

const reversal = await refundOrder(orderId, "full", "customer_request", "agent_42");
console.log({
  ledger_entry: "refund",
  amount_cents: reversal.amount,
  parent_transfer: reversal.parent_transfer,
  reversal_id: reversal.id,
  state: reversal.state,
});

Tradeoffs

  • No dedicated list endpoint. To find every reversal for a transfer, list transfers and filter by type === "REVERSAL" and parent_transfer === originalId, or follow _links.reversals.href on the parent.
  • Partial-refund accumulation. Multiple partial refunds add up against the original capture. The API returns 422 once you exceed it — handle that case in your service.
  • Pending state. A new reversal starts as state: "PENDING" and only flips to SUCCEEDED when the funds clear back. Don't notify the customer "refunded" until you've seen the payment.updated (or refund-specific) webhook flip the state.
  • Disputes vs refunds. A refund issued after a dispute is opened may not stop the dispute fee — acceptDispute is the cleanest way to close the case in the cardholder's favor.
  • Subscription invoice refunds. Refunding a subscription's invoice transfer doesn't cancel the subscription. If the customer wants to stop billing too, also call cancelSubscription(subId, { at_period_end: false }).

On this page