@pandait.tech/payment-nuvei
v1.0.1
Published
Nuvei Ecuador payment gateway adapter for Next.js: charge handler, webhook, 3DS flow, refunds, tokenized cards, and payment links. Part of panda-commerce-kit.
Maintainers
Readme
@pandait.tech/payment-nuvei
Nuvei Ecuador payment gateway adapter for Next.js. Ships:
- SDK wrapper for Nuvei's REST endpoints (debit, verify, refund, card list/delete).
- Route-handler factories for
/api/payment/*and/api/webhooks/nuvei— drop-in for Next.js App Router. - Payment-link primitives (token gen, expiry helpers, anonymous-auth helper).
- React UI components (
CardVisual,SavedCards,NuveiPaymentForm) with bank-color branding driven by the package's BIN/bank-colors database.
Extracted from pauhenriques-website (production-validated).
Status
V0 — Phase 1 of panda-commerce-kit. Published to public npm.
Install
pnpm add @pandait.tech/payment-nuveiPeer deps the consumer must install:
| Peer | When required |
|---|---|
| next >= 14 | Always (handlers + UI use next/server and next/script) |
| react >= 18 | Only if importing /ui |
| react-icons >= 5 | Only if importing /ui |
| zod >= 3.22 | Always (used by 3ds-complete handler validation) |
| firebase-admin >= 12 | Always (handlers need Firestore + Auth admin SDKs) |
| firebase >= 10 | Only if importing createAnonymousAuthHelper |
Public API
// Pure SDK functions (server-side)
import {
debitWithToken,
verifyThreeDS,
refundTransaction,
listCards,
deleteCard,
verifyCard,
} from "@pandait.tech/payment-nuvei";
// Route handler factories
import {
createChargeHandler,
createWebhookHandler,
create3dsCallbackHandler,
create3dsCompleteHandler,
create3dsTimeoutHandler,
createRefundHandler,
createInitCheckoutHandler,
createCardsHandler,
createVerifyHandler,
createTestChargeHandler,
} from "@pandait.tech/payment-nuvei/handlers";
// Payment-link primitives
import {
generatePaymentLinkToken,
defaultExpiry,
isExpired,
createAnonymousAuthHelper,
PAYMENT_LINK_PRICE_MIN,
PAYMENT_LINK_PRICE_MAX,
} from "@pandait.tech/payment-nuvei/payment-links";
// React UI components
import {
CardVisual,
SavedCards,
NuveiPaymentForm,
// utilities
getBinDatabase,
lookupBin,
lookupBankPalette,
} from "@pandait.tech/payment-nuvei/ui";Handler factories — usage
// app/api/payment/charge/route.ts
import { createChargeHandler } from "@pandait.tech/payment-nuvei/handlers";
import { dbAdmin, authAdmin } from "@/lib/firebase";
import { sendPaymentConfirmation, sendPaymentFailed } from "@/lib/email";
import { getPriceDisplay } from "@/lib/pricing";
export const POST = createChargeHandler({
firebase: { db: dbAdmin, auth: authAdmin },
email: { sendPaymentConfirmation, sendPaymentFailed },
getPriceDisplay,
merchantName: "Acme Shop",
// Required for 3DS to work. Without it, browser-info is sent to Nuvei but
// 3DS challenges won't initiate — the handler logs a loud error if browserInfo
// is provided but cloudFunctionsBaseUrl is missing.
cloudFunctionsBaseUrl: process.env.CLOUD_FUNCTIONS_BASE_URL,
rateLimit: checkRateLimit, // optional
turnstile: verifyTurnstile, // optional
// Optional consumer-specific side effects after order transitions to paid
onPaymentSucceeded: async (order) => {
await ensureFulfillment(order.id);
},
// Optional retry URL for payment-failed emails
getRetryUrl: (order) => `https://shop.example.com/checkout?orderId=${order.id}`,
});Apply the same pattern to the remaining handlers — each one is documented inline with its own XHandlerDeps interface.
The webhook is OPTIONAL — and if you expose it, secure it
First, set expectations correctly:
- Payments are confirmed by the synchronous
/debitresponse (createChargeHandlerreadstransaction.status === "success" && status_detail === 3), plus the 3DSterm_urlcallback +verifypolling for challenged transactions. That path fully confirms a payment without any webhook. - Paymentez's transaction-notification callback (
developers.paymentez.com/api/#webhook) is an optional, redundant async channel — Paymentez POSTs transaction status to a URL you register with Paymentez during integration (there is no documented self-serve dashboard; for many accounts it is simply never wired up). In a real production consumer we observed Paymentez never calling it at all. - So
createWebhookHandleris a reconciliation backstop you can mount if your Paymentez account is configured to notify it. It is not required to take payments.
Because the endpoint is nonetheless a public POST route, if you mount it you must authenticate it — otherwise anyone who knows an orderId could POST a fake "paid" notification (status_detail: 3) and mark an order paid. Paymentez callbacks are not signed, so use a shared secret:
Pass a secret to the handler (or via
NUVEI_WEBHOOK_SECRET):export const POST = createWebhookHandler({ firebase: { db: dbAdmin, auth: authAdmin }, email: { sendPaymentConfirmation, sendPaymentPending }, webhookSecret: process.env.NUVEI_WEBHOOK_SECRET, onPaymentSucceeded: async (order) => { /* enrollment, fulfillment, ... */ }, });If/when you register the notification URL with Paymentez, append the secret as a query param:
https://your-shop.com/api/webhooks/nuvei?key=YOUR_SECRET(The secret is also accepted as the
x-webhook-keyheader.)
Requests without a matching key get 401 and are not processed (constant-time compared). If webhookSecret is unset, the handler still processes but logs a loud warning. If you never register the URL with Paymentez, setting the secret simply seals the endpoint (everything → 401), which is a safe default.
onPaymentSucceededfires once, on the transition intopaid(idempotent across retries).
Hosting & deployment (3DS callback + Nuvei egress)
This package is hosting-agnostic by design — the payment logic doesn't care where it runs. But two parts of the Nuvei flow may need to run outside your app's request edge depending on where you host, because of how the host's infrastructure (not this package) handles certain traffic:
1. The 3DS callback (termUrl)
After a 3DS challenge, the bank's ACS makes an external, cross-origin POST to the termUrl. The termUrl is built by createChargeHandler as:
${cloudFunctionsBaseUrl}/threeDSCallback?orderId=<id>The callback must: read the cres from the POST (form-urlencoded or JSON, casing varies by issuer), decode transStatus from the base64url CRES payload, persist threeDSCres + threeDSTransStatus on the order (only while status === "3ds-pending"), and return HTML that postMessages { type: "3DS_COMPLETE", orderId, transStatus } to the parent checkout window (no redirect).
| Your host | How to expose the callback |
|---|---|
| Firebase App Hosting / Cloud Run | App Hosting returns 403 to the bank's external POST before your code runs (infra-level block). You must deploy the callback as a standalone Cloud Function with permissive ingress (cors: true) and point cloudFunctionsBaseUrl at https://<region>-<project>.cloudfunctions.net. The package's create3dsCallbackHandler (a Next.js route) is not usable as termUrl here. |
| Vercel / Netlify / plain Node / properly-configured Cloud Run | The Next.js route from create3dsCallbackHandler accepts the external POST fine. Mount it at /api/payment/3ds-callback and set cloudFunctionsBaseUrl to your own origin + that path. |
2. Nuvei API egress
Some hosts can't reach Nuvei's API directly. Cloud Run (App Hosting) returns 500 calling ccapi.paymentez.com (egress/TLS incompatibility). Work around it with a tiny proxy (a Cloud Function works) and set the NUVEI_PROXY_URL env — the SDK routes through it automatically when present. On hosts without this limitation, leave NUVEI_PROXY_URL unset and the SDK calls Nuvei directly.
Reference implementation
A production-validated implementation of both (threeDSCallback + nuveiProxy, Firebase Functions v2) lives in pauhenriques-website/functions/index.js. The logic is business-agnostic — copy it and point your env vars at the deployed functions.
Roadmap (v0.next): these will ship from the package as framework-neutral handlers (Web-standard
Request → Response) plus thin runtime adapters (toCloudFunction,toExpress), so a single source of truth can deploy to a Next.js route, a Cloud Function, or any runtime — eliminating the hand-maintainedfunctions/index.jsduplication. The package will stay agnostic to hosting and framework; it remains coupled to Firebase (Firestore/Auth) as the data layer by design.
UI components — usage
All /ui exports are client components ("use client"). They consume CSS variables for theming so the same components work for every white-label client with their own branding.
1. Load the styles
You have two options. Option A is recommended — it works without Tailwind, on any Tailwind version, and doesn't require configuring your build to scan this package.
Option A — import the prebuilt stylesheet (recommended, standalone):
// once, e.g. in app/layout.tsx or your global entry
import "@pandait.tech/payment-nuvei/ui/styles.css";This ships the exact utility classes the components use (no Tailwind Preflight/reset — it won't touch your page's base styles). You still define the brand tokens (step 2) for theming.
Option B — let your own Tailwind build the classes (Tailwind v4 users):
Skip the CSS import and instead point Tailwind at the package so it generates the classes itself. Tailwind v4 (@source in your CSS):
@import "tailwindcss";
@source "../node_modules/@pandait.tech/payment-nuvei/dist/**/*.{js,cjs}";Tailwind v3 (content in tailwind.config):
export default {
content: [
"./app/**/*.{ts,tsx}",
"./node_modules/@pandait.tech/payment-nuvei/dist/**/*.{js,cjs}",
],
};If components render unstyled, you forgot Option A's import or Option B's
@source/contententry. Option A removes that failure mode entirely.
2. Define CSS variables in your global stylesheet
Add this to your app/globals.css (Next.js) or equivalent, in :root:
:root {
/* Brand */
--color-primary: #a68a63;
--color-primary-hover: #b89a73;
/* Text */
--color-text-main: #c1c4a7;
/* Surfaces */
--color-background: #343d2a;
--color-surface-elevated: #475536;
/* Borders */
--color-border-default: rgba(193, 196, 167, 0.20);
--color-border-strong: rgba(193, 196, 167, 0.35);
--color-border-subtle: rgba(193, 196, 167, 0.12);
/* Status */
--color-error: #c75c4a;
--color-warning: #c9a84c;
}
/* Optional: dark mode palette */
.dark {
--color-primary: #b89a73;
/* ...override as needed */
}3. Use the components
"use client";
import { SavedCards, NuveiPaymentForm } from "@pandait.tech/payment-nuvei/ui";
export function PaymentMethodPicker() {
const [token, setToken] = useState<string | null>(null);
return (
<div>
<SavedCards
selectedToken={token}
onSelectCard={(card, cvc) => { /* ... */ }}
onAddNewCard={() => { /* show NuveiPaymentForm */ }}
/>
<NuveiPaymentForm
uid={user.uid}
email={user.email}
onTokenSuccess={(token, cardInfo, saveCard) => { /* call /api/payment/charge */ }}
onTokenError={(err) => { /* ... */ }}
/>
</div>
);
}The NuveiPaymentForm reads NEXT_PUBLIC_NUVEI_CLIENT_APP_CODE, NEXT_PUBLIC_NUVEI_CLIENT_APP_KEY, and NEXT_PUBLIC_NUVEI_ENV from your env by default. You can override per-instance with the nuveiAppCode / nuveiAppKey / nuveiEnv props.
Endpoints the UI components expect
The components POST/GET to fixed paths because the package's handler factories are designed to mount there:
| Component / call | Endpoint | Handler factory |
|---|---|---|
| SavedCards GET on mount | /api/nuvei/cards | createCardsHandler GET |
| SavedCards DELETE on remove | /api/nuvei/cards | createCardsHandler DELETE |
| SavedCards POST on verify | /api/nuvei/verify | createVerifyHandler POST |
| NuveiPaymentForm DELETE on duplicate | /api/nuvei/cards | createCardsHandler DELETE |
| CardVisual BIN lookup | /api/bins (overridable) | Consumer-provided JSON of BinDatabase |
The consumer must wire those routes — each route.ts is one or two lines re-exporting the factory result.
CardVisual degrades gracefully if no BIN endpoint is served: it just renders without bank-specific colors. The endpoint is optional and overridable via the binDatabaseEndpoint prop (default /api/bins).
Firestore data contract
The handlers read/write a single orders collection (plus promotions for coupon usage). Your Firestore schema must match these names, statuses, and fields:
orders/{orderId} — the document the charge/webhook/3DS/refund handlers operate on. dev_reference sent to Nuvei is the orderId.
| Field | Written by | Notes |
|---|---|---|
| status | all | lifecycle: pending → 3ds-pending → paid | cancelled | refunded. Final statuses (paid/delivered/shipped) are never downgraded by the webhook. |
| userId | consumer (at create) | must match the __session cookie uid; handlers authorize against it. |
| userEmail | consumer | used for confirmation emails. |
| items, subtotal, vat, total, discount | consumer | echoed into emails. |
| promotionId, couponCode | consumer | if set, webhook/charge increment promotions/{id}.currentUses and write a promotions/{id}/usages/{auto} doc atomically. |
| paymentTransactionId, authorizationCode, nuveiTransactionId | charge/webhook | Nuvei refs; paymentTransactionId is required for refunds. |
| threeDSCres, threeDSTransStatus | 3DS callback / webhook | set during the challenge; cleared on completion. |
| verifyCalledAt | webhook | guards the BY_CRES verify path (single-fire). |
| emailPending, missingAuthCodeFlagged | charge/webhook | pending-email reconciliation when auth_code arrives late. |
| deleteCardAfterPayment, paymentToken | charge | "don't save card" opt-out, honored on 3DS/webhook completion. |
| shippingAddress.fullName | consumer | used as the email customer name. |
| refundedAt | refund | set on successful refund. |
promotions/{promotionId}: currentUses (incremented). promotions/{promotionId}/usages/{auto}: { userId, orderId, discountApplied, usedAt }.
Business-specific side effects (course enrollment, fulfillment, paymentLink usage) live in your
onPaymentSucceededcallback — the package never assumes those collections.onPaymentSucceededfires once, on the transition intopaid(idempotent across Nuvei webhook retries), but you should still make it internally idempotent as defense-in-depth.
API surface & stability
Public, supported entry points: the package root (SDK), ./handlers, ./adapters, ./payment-links, ./ui, and ./ui/styles.css. Everything else (e.g. internal http.ts) is private and may change without notice.
As of 1.0.0 the public API is frozen under semver — breaking changes to the
exported factories, their *HandlerDeps interfaces, the SDK functions, or the UI
component props require a major version bump. See CHANGELOG.md.
Design notes consumers should know:
- Order shape is generic.
MinimalOrderextendsRecord<string, unknown>and declares only fields the package itself reads/writes. Your business-specific order fields are yours to read off the order inside your callbacks (onPaymentSucceeded,validateCustomOrder,getRetryUrl) — narrow them there. - Payment links are primitives only. The package ships token/expiry/price-bound helpers; the payment-link document shape (what it points to) is yours to define.
- The webhook secret is optional but strongly recommended. It's left optional so
the package never forces a specific webhook-URL scheme, but an unconfigured
webhook is forgeable — always set
webhookSecretin production (see "Securing the webhook" above).
Migration from pauhenriques-website
See per-file Source: pauhenriques-website/... headers throughout the package. The refactor preserves behavior with three intentional fixes vs. the original (documented in commit 4b0cb06 — coupon-reuse in webhook BY_CRES + card-delete on 3DS flows).
