@nexus-pay/node
v0.1.1
Published
NexusPay Node server SDK — typed payments client + webhook verification
Maintainers
Readme
@nexus-pay/node
Typed NexusPay server SDK for Node.js (>=18): a payments API client plus
webhook signature verification. Zero runtime dependencies (uses the global
fetch/AbortController).
npm install @nexus-pay/nodeClient
import { NexusPay } from '@nexus-pay/node';
const nexus = new NexusPay({
apiKey: process.env.NEXUSPAY_API_KEY!, // "sk_test_…" / "sk_live_…"; never logged
baseUrl: process.env.NEXUSPAY_API_URL!, // your NexusPay base URL, e.g. http://localhost:8090
});
// `capture: true` is the boolean alias for capture_method: 'automatic'
// (`false` -> 'manual'). Pass an idempotencyKey to make the create safely
// retryable — a retry with the SAME key returns the original payment. Use a fresh
// key per ATTEMPT (not one stable across distinct purchases), or a repeat buy is
// silently served the first attempt's cached response for 24h.
const payment = await nexus.createPayment(
{ amount: 1000, currency: 'USD', capture: true },
{ idempotencyKey: crypto.randomUUID() },
);
console.log(payment.id, payment.status);amount is in the currency's minor unit (e.g. cents). Non-2xx responses throw a
NexusPayError carrying the gateway's type / code / message / requestId.
A request timeout rejects with NexusPayError (type: 'network_error',
code: 'timeout').
0.1.1 behavior change — cancellation vs timeout. If you pass your own
AbortSignalvia the per-call{ signal }option and then abort it, the request now rejects with the rawAbortError(aDOMException), not aNexusPayError. In 0.1.0 a caller abort was mis-mapped toNexusPayError(code: 'timeout'). A genuine timeout is unchanged (stillNexusPayError). If yourcatchcheckserr instanceof NexusPayErrorand you cancel via{ signal }, also handle theAbortError(err.name === 'AbortError'). Callers that don't use{ signal }are unaffected.
Verifying webhooks
⚠️ Pass the EXACT raw request body — the bytes received off the wire, as a
stringorBuffer. The signature is an HMAC over those exact bytes; re-serializing the parsed JSON (JSON.stringify(req.body)) reorders/reformats keys and will break verification. This is the #1 webhook integration failure. Mount a raw-body parser on the webhook route and never let JSON middleware touch it first.
import express from 'express';
import { constructEvent, SignatureVerificationError } from '@nexus-pay/node';
const app = express();
// Raw body ONLY on the webhook route — `req.body` is a Buffer here.
app.post(
'/webhooks/nexuspay',
express.raw({ type: 'application/json' }),
(req, res) => {
try {
const event = constructEvent(
req.body, // the raw Buffer — NOT a parsed object, NOT re-stringified
req.headers, // case-insensitive X-NexusPay-Signature / -Timestamp lookup
process.env.NEXUSPAY_WEBHOOK_SECRET!, // "whsec_..."
// OPTIONAL hardened replay window — see note below.
{ createdToleranceSeconds: 300 },
);
switch (event.type) {
case 'payment.succeeded':
// event.data.object is the payment; event.data.metadata is your map.
break;
default:
break;
}
res.sendStatus(200);
} catch (err) {
if (err instanceof SignatureVerificationError) {
return res.status(400).send(`Webhook error: ${err.code}`);
}
throw err;
}
},
);constructEvent verifies the signature (timing-safe), optionally enforces a
replay window, then parses the body into a typed WebhookEvent. It throws
SignatureVerificationError (with code: missing_signature,
invalid_signature, timestamp_out_of_tolerance, or invalid_payload) on any
failure. Use verifyWebhook(rawBody, signature, secret) if you only need the
boolean.
Replay protection
There are two replay-window options, and they are not equivalent:
createdToleranceSeconds— hardened. Anchors on the signedcreatedfield inside the verified envelope, which is covered by the HMAC and cannot be forged or rewritten by a replayer. Prefer this.toleranceSeconds— advisory only. Anchors on theX-NexusPay-Timestampheader, which the platform does not include in the HMAC. An attacker who captures a valid delivery can replay the exact body + signature while rewriting that header to "now", and the check (and signature) will still pass. Treat it as a coarse freshness hint, not a security control.
