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/nodeinstalled.- A
customerIdfor the buyer (returned bycreateCustomer). - A Payment Instrument
idbelonging to that Customer (returned bycreatePaymentInstrument, or fetched viagetCustomerPaymentInstruments).
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.