Easy Labs
PaymentsGuides

Accept a card payment

Charge a saved Payment Instrument server-to-server with @easylabs/node.

Goal

Charge an existing Customer's saved card for an arbitrary amount, without rendering a checkout UI. This is the right pattern for headless flows: post-purchase upsells, scheduled jobs that bill on your own cadence, retry of a failed payment, or any backend that already knows which Customer + Payment Instrument to charge.

If you do not yet have a saved Payment Instrument for the Customer, see Embed hosted checkout first — that flow tokenizes the card safely and saves the instrument for re-use.

Prerequisites

  • Easy Labs API key (sandbox or production) — see Quickstart.
  • @easylabs/node installed.
  • A customerId for the buyer (returned by createCustomer).
  • A Payment Instrument id belonging to that Customer (returned by createPaymentInstrument, or fetched via getCustomerPaymentInstruments).

Implementation

1. Initialize the client

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

const easy = await createClient({ apiKey: process.env.EASY_API_KEY! });

createClient validates the key against the Easy Labs API before resolving, so a misconfigured environment fails fast at startup rather than on first charge.

2. Look up (or pick) the Payment Instrument

const { data: instruments } = await easy.getCustomerPaymentInstruments(
  customerId,
);
const card = instruments.find((i) => i.type === "PAYMENT_CARD" && i.enabled);
if (!card) throw new Error("No active card on file");

In production you typically store the Payment Instrument id on your own customer record at the time of save, instead of re-fetching it on every charge.

3. Create the Transfer

const { data: transfer } = await easy.createTransfer({
  amount: 4999,        // $49.99 in cents
  currency: "USD",
  source: card.id,
  tags: { internal_order_id: "order_123" },
});

if (transfer.state === "FAILED") {
  // Surface a buyer-friendly retry path.
  throw new Error(transfer.failure_message ?? "Payment failed");
}

A Transfer with state: "SUCCEEDED" is a captured charge — funds will appear in the next merchant Settlement once ready_to_settle_at passes. state: "PENDING" means the processor is still working; subscribe to the payment.updated webhook to be notified of the terminal state without polling.

4. (Optional) React to webhooks

If you want server-side confirmation rather than relying on the synchronous response, register a webhook endpoint and verify deliveries with EasyWebhooks.constructEvent:

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

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 === "payment.updated") {
    // event.data is the Transfer
  }
  res.status(204).end();
});

Tradeoffs

  • This pattern requires that the buyer has previously consented to save a card with you. For the first charge, use Embed hosted checkout — it tokenizes safely and returns a re-usable instrument id.
  • Direct Transfers do not produce an Order. If you need line-item bookkeeping, use client.checkout({ … }) instead, which produces both a Transfer and an Order with line items.
  • Refunds are issued against the Transfer ID — not the Customer or instrument. See Issue a refund.

On this page