@x-money/core
The TypeScript server SDK for the X Money API. Create checkout sessions, charge linked accounts, issue refunds and payouts, and verify webhooks.
Zero runtime dependencies, ESM only, Node 24 or newer.
npm install @x-money/core
Quick start
import { XMoney } from "@x-money/core";
// The key is always explicit. Read it from wherever you keep secrets; the SDK
// will not pick one up from the environment on its own.
const apiKey = process.env.XMONEY_API_KEY;
if (!apiKey) throw new Error("Missing XMONEY_API_KEY");
const xmoney = new XMoney(apiKey);
const session = await xmoney.checkouts.create({
line_items: [
{ name: "PlayStation 5", amount: 49999, currency: "USD", quantity: 1 },
],
success_url: "https://example.com/success",
failure_url: "https://example.com/failure",
reference: "order-2481",
});
// Send the customer to session.url, or hand it to @x-money/checkout in the
// browser to open it in a popup instead of redirecting.
Amounts are integers in the currency's smallest unit, so 49999 is $499.99.
Resources
checkouts, exchanges, accounts, customers, payments, refunds, payouts, settlements, and webhooks.
Every endpoint the API exposes has a method here. The SDK's own test suite checks the two against each other in both directions, so an endpoint cannot be added to the API without a matching method shipping in the next release.
Pagination
Listable resources have a list method. Await it for one page, or for await to walk every page. Checkout sessions and account exchanges have no list in the API.
const firstPage = await xmoney.payouts.list({ limit: 100 });
for await (const payout of xmoney.payouts.list({ limit: 100 })) {
console.log(payout.id);
}
Idempotency
Every POST carries a generated idempotency key. Pass your own to make a retry safe across process restarts, which is what you want for anything driven by an order id.
await xmoney.refunds.create(
{ payment: "xmpy_...", reference: "refund-order-8471" },
{ idempotencyKey: "refund-order-8471" },
);
Test mode
With a test key, testOutcome sends the Xm-Test-Outcome header to force a decline, an expiry, or an authorization error on that request. The amount never picks the outcome.
await xmoney.payments.create(
{ customer_id: "xmcu_...", account_id: "xmac_...", amount: 2437, currency: "USD" },
{ testOutcome: "INSUFFICIENT_FUNDS" },
);
Webhooks
constructEvent verifies the xm-signature header and returns a typed event. It throws XMoneySignatureVerificationError on a bad signature, a malformed header, or a timestamp outside the 300 second replay window.
import { XMoney, XMoneyUnknownEventError, XMoneySignatureVerificationError } from "@x-money/core";
app.post("/webhooks", express.raw({ type: "*/*" }), async (req, res) => {
let event;
try {
event = xmoney.webhooks.constructEvent(
req.body,
req.headers["xm-signature"],
process.env.XMONEY_WEBHOOK_SECRET,
);
} catch (err) {
if (err instanceof XMoneyUnknownEventError) return res.status(200).end();
if (err instanceof XMoneySignatureVerificationError) return res.status(400).end();
throw err;
}
if (event.type === "checkout_session_completed") {
await fulfillOrder(event.event.merchant_reference);
}
res.status(200).end();
});
req.body must be the unparsed request body. A body that has been JSON-parsed and re-serialized will not match the signature. Express needs express.raw() on this route rather than express.json().
An unrecognized event type is a genuine delivery this package is older than. Catch XMoneyUnknownEventError and return 200, or the endpoint is retried until it ages out.
To sign a payload in tests, generateTestHeader returns an xm-signature value constructEvent will accept:
const header = xmoney.webhooks.generateTestHeader(rawBody, secret);
const event = xmoney.webhooks.constructEvent(rawBody, header, secret);
constructEvent uses node:crypto and is sync. constructEventAsync HMAC-signs with Web Crypto (crypto.subtle) instead of createHmac. Same arguments, same errors, and it accepts headers generateTestHeader produced. A Fetch Request still needs await request.text(). The rest of this package still imports node:crypto for the HTTP client, so run the webhook route on Node until a webhooks-only export exists.
const event = await xmoney.webhooks.constructEventAsync(
await request.text(),
request.headers.get("xm-signature"),
process.env.XMONEY_WEBHOOK_SECRET,
);
Express checkout callbacks
A checkout session can ask for customer details and let you answer while the customer is still in checkout:
const session = await xmoney.checkouts.create({
line_items: [{ name: "Starter kit", amount: 1986, currency: "USD", quantity: 1 }],
reference: "order-2481",
requested_customer_details: ["EMAIL", "SHIPPING_ADDRESS"],
allowed_shipping_countries: ["US"],
callback_events: ["CUSTOMER_DETAILS_UPDATED", "CONFIRMATION_REQUESTED"],
});
X Money then posts to your callback endpoint, which is registered separately from your webhook endpoint and has its own xmcbsk_ signing secret. checkout_session_customer_details_updated arrives when checkout opens and whenever the customer changes their shipping address or email; answer with tax for that address, or reject it. checkout_session_confirmation_requested arrives when they pay, before any money moves; approve or reject the order. The customer waits on your answer, so reply within 5 seconds. A timeout, a non-200 status, or a body that is not a decision fails the step, and the customer can retry.
app.post("/xmoney/callbacks", express.raw({ type: "*/*" }), (req, res) => {
let callback;
try {
callback = xmoney.callbacks.constructEvent(
req.body,
req.headers["xm-signature"],
process.env.XMONEY_CALLBACK_SECRET,
);
} catch {
return res.status(400).end();
}
const address = callback.event.customer_details?.shipping_address;
if (callback.type === "checkout_session_customer_details_updated") {
if (address && /^p\.?\s*o\.?\s*box/i.test(address.street)) {
return res.json(xmoney.callbacks.reject({ field: "SHIPPING_ADDRESS", message: "We can't ship to PO boxes" }));
}
return res.json(xmoney.callbacks.approve({ tax_line_items: [{ name: "Sales tax", amount: taxFor(address), currency: "USD" }] }));
}
res.json(xmoney.callbacks.approve());
});
approve() without tax leaves the session's tax unchanged, and only checkout_session_customer_details_updated accepts tax. reject() without a field rejects the whole order. A rejected_message is shown to the customer and is limited to 100 characters. constructEventAsync and generateTestHeader work as they do for webhooks.
Errors
| Error | Raised when |
|---|---|
XMoneyError | Base class. Every SDK error is one. |
XMoneyMissingApiKeyError | No API key was passed to the constructor. |
XMoneyInvalidApiKeyError | The key is not an xmsk_test_ / xmsk_prod_ secret key. |
XMoneyInvalidBaseUrlError | baseUrl was passed and is not an absolute URL. |
XMoneyAPIError | The API returned a non-2xx. Carries a normalized code, the HTTP status, and requestId. |
XMoneyConnectionError | The request never got a response. code is timeout, aborted, or network. |
XMoneySignatureVerificationError | A webhook or callback signature did not verify, or the signing secret is the wrong kind (xmwhsk_ for webhooks, xmcbsk_ for callbacks). |
XMoneyUnknownEventError | The signature verified, but this package has no type for the event. Catch it and return 200. |
Requests time out after 30 seconds and retry twice on 429s, 5xxs, and network failures, with jittered exponential backoff that honors Retry-After. Both are configurable per client, and the timeout per request.
const xmoney = new XMoney({
apiKey,
timeoutMs: 10_000,
maxRetries: 5,
onResponse: ({ requestId, status, method, path }) => {
console.log(method, path, status, requestId);
},
});
The package ships a source map with the TypeScript sources embedded. Node applies it to stack traces only when started with --enable-source-maps, or after process.setSourceMapsEnabled(true). Without one of those, a trace from inside the SDK points into the minified bundle.
A note on int64
The API serializes 64-bit integers as JSON strings, since large values do not survive JavaScript numbers. The SDK converts the known int64 fields back to numbers on the way out, so amounts and timestamps arrive as you would expect.
metadata is skipped, so your own data is never rewritten.
Documentation
Guides, the API reference, and the webhook catalog are at xmoney.mintlify.site/docs.
To have an AI coding agent collect a payment with this package:
npx skills add https://xmoney.mintlify.site/docs
The browser half of the SDK, @x-money/checkout, renders the Pay with X button and opens the sessions this package creates.
Support
Report bugs and ask questions through the X Help Center. Security issues go through the same form; please do not post them publicly.
License
MIT. The full text ships in the package as LICENSE.