@nomalism-com/eupago
v0.1.2
Published
TypeScript SDK for the EuPago REST API (Multibanco, MB WAY, credit card, refunds and queries).
Readme
@nomalism-com/eupago
A small, typed TypeScript client for the EuPago REST API — the Portuguese payment provider behind Multibanco references, MB WAY, and card payments.
It is a thin wrapper over fetch: no framework, no runtime dependencies, no
global state. You create a client, you call a method, you get JSON back.
import { EuPago } from "@nomalism-com/eupago";
const eupago = new EuPago({ environment: "sandbox", apiKey: "your-api-key" });
const reference = await eupago.MultibancoReference({
id: "order-1234",
valor: 19.99,
});
// → { referencia: "999999999", entidade: "12345", ... }Why this exists
EuPago exposes three generations of API side by side, each with a different base URL and a different authentication scheme:
| API | Base path | Auth | Used by |
| ---------------- | ------------------------ | ------------------------------ | --------------------- |
| Legacy REST | /clientes/rest_api/ | API key in the body as chave | Multibanco references |
| v1.02 | /api/v1.02/ | Authorization: ApiKey <key> | MB WAY, credit card |
| Management v1.02 | /api/management/v1.02/ | OAuth 2.0 bearer token | Refunds, all queries |
Getting that wrong is the single most common way an EuPago integration fails, and the errors it produces are unhelpful. This package picks the right base URL, the right auth header and the right body shape for each call, and handles the OAuth token lifecycle for you.
Installation
npm install @nomalism-com/eupagoRequires Node 24 or later (see engines). Ships ESM and CommonJS builds with type definitions for both, and has no runtime dependencies.
The package has named exports only — there is no default export, so ESM and CommonJS have exactly the same shape:
import { EuPago } from "@nomalism-com/eupago"; // ESM
const { EuPago } = require("@nomalism-com/eupago"); // CJSCredentials
EuPago issues two independent sets of credentials, from your account dashboard:
- An API key ("chave") — used to create payments.
- An OAuth 2.0 client id and secret — used for refunds and queries.
You need whichever the calls you make require, and only those. A client built with just an API key can create payments; one built with just the OAuth pair can run refunds and queries. Credentials are validated when a method is called, not when the client is constructed, so nothing forces you to supply both.
Sandbox and production use different credentials and different hosts. The
environment option selects the host set for you.
Usage
Creating the client
import { EuPago } from "@nomalism-com/eupago";
const eupago = new EuPago({
environment: "production", // or "sandbox"
apiKey: process.env.EUPAGO_API_KEY,
clientId: process.env.EUPAGO_CLIENT_ID,
clientSecret: process.env.EUPAGO_CLIENT_SECRET,
redirectUrls: {
success: "https://example.com/payment/success",
fail: "https://example.com/payment/failed",
back: "https://example.com/cart",
},
});The client reads nothing from process.env itself — every value is passed in.
That means one process can hold several clients at once (different merchants, or
sandbox and production side by side) with no interference between them.
Create the client once and reuse it. The OAuth token is cached inside the instance, so building a new client per request re-authenticates every time.
Constructor options
| Option | Type | Required | Default | Notes |
| --------------------------- | --------------------------- | ----------- | ------- | ------------------------------------------------------ |
| environment | "sandbox" \| "production" | yes | — | No default, so the choice is always deliberate. |
| apiKey | string | conditional | — | Needed by Multibanco, MB WAY and credit card. |
| clientId / clientSecret | string | conditional | — | Needed by refunds and every query (OAuth 2.0). |
| redirectUrls | { success, fail, back } | conditional | "" | success and fail are required by CreditCard. |
| allowDuplicatePayments | boolean | no | false | EuPago's per_dup — more than one reference per id. |
| notifyCustomer | boolean | no | false | Asks EuPago to email/SMS the customer. |
| timeoutMs | number | no | 15000 | Per-request timeout. |
| baseUrls | Partial<EuPagoBaseUrls> | no | derived | Escape hatch for staging hosts or an API version bump. |
| fetch | typeof fetch | no | global | Inject an instrumented or proxied fetch. |
Every optional field explicitly accepts undefined, so you can pass
process.env.X straight through even with exactOptionalPropertyTypes on.
The resolved base URLs are readable at eupago.baseUrls, which is useful when
you want to log or assert which environment a client is pointing at.
Payments
// Multibanco reference — the customer pays at an ATM or in their bank app
const ref = await eupago.MultibancoReference({ id: "order-1", valor: 19.99 });
// ...with a payment window (dates are YYYY-MM-DD)
const dated = await eupago.MultibancoReferenceWithDeadline({
id: "order-2",
valor: 19.99,
data_inicio: "2026-01-10",
data_fim: "2026-02-10",
});
// MB WAY — pushes a notification to the customer's phone
const mbway = await eupago.Mbway({
name: "Ana Silva",
email: "[email protected]",
customerPhone: "912345678",
value: 19.99,
identifier: "order-3",
countryCode: "+351", // optional, this is the default
});
// Credit card — returns the 3-D Secure form to redirect the customer to
const card = await eupago.CreditCard({
email: "[email protected]",
value: 19.99,
identifier: "order-4",
});
redirect(card.redirectUrl);Note the field names follow EuPago's own API rather than being normalised:
Multibanco uses id/valor, the newer endpoints use identifier/value. That
is deliberate — it keeps this package a transport layer you can check against
EuPago's documentation, rather than a second vocabulary to learn.
Refunds and queries
These use OAuth, and need clientId/clientSecret.
await eupago.Refund({
identifier: trid,
amount: 19.99,
reason: "Out of stock",
});
await eupago.RefundInformation({ refundId });
await eupago.Payouts({ start_date: "2026-01-01", end_date: "2026-01-31" });
await eupago.PayoutsTransactions({ start_date, end_date });
await eupago.TRIDInformation({ trid });
await eupago.Transactions({ status: "paga" });
await eupago.ReferencesByStatus({ referenceStatus: "pendente" });
await eupago.AdvancedSearch({ identifier: "order-1" });Refund and TRIDInformation take EuPago's internal transaction reference id
(trid) — not the identifier you sent when creating the payment, and not
the transactionID returned by the payment endpoints. See
Known limitations.
Every method is also available on its module — eupago.payments,
eupago.refunds, eupago.queries — if you prefer to pass a narrower object
around. The top-level methods just delegate.
Authentication
Handled automatically. The bearer token is cached until 30 seconds before it
expires, and concurrent calls are de-duplicated, so a burst of parallel queries
issues a single POST /auth/token rather than one per request. A failed
authentication is never cached.
eupago.authentication.clearCache() forces re-authentication if you need it.
EuPago's OpenAPI description documents
expires_inas an absolute timestamp string ("2022-01-10 10:47:41"), but the live API returns seconds until expiry (e.g.3600). This package accepts both.
Error handling
import {
EuPago,
EuPagoConfigError, // a required option is missing or invalid
EuPagoHttpError, // non-2xx response, or a body that isn't JSON
EuPagoNetworkError, // DNS, connection, or timeout — no response at all
EuPagoError, // base class of all of the above
} from "@nomalism-com/eupago";
try {
const ref = await eupago.MultibancoReference({ id: "order-1", valor: 19.99 });
} catch (error) {
if (error instanceof EuPagoHttpError) {
logger.error({ status: error.status, body: error.body }, "EuPago rejected");
} else if (error instanceof EuPagoNetworkError) {
// error.timedOut distinguishes a timeout from a connection failure
scheduleRetry();
} else if (error instanceof EuPagoConfigError) {
throw error; // a deployment problem, not a runtime one
}
}Business failures are not exceptions
This is the most important thing to know about the EuPago API: it answers
200 OK for business-level failures. An unknown refund id, an empty result
set, a declined payment — all arrive as a successful HTTP response with a
failure code in the payload:
{ "transactionStatus": "Error" }
{ "code": "REFUND_INVALID", "text": "..." }
{ "code": "EMPTY_RESULT" }This package returns those to you as normal values and does not throw, because
only the caller knows whether a given code is exceptional in context —
EMPTY_RESULT from a report query is routine; the same code from a lookup you
expected to match is not. Check the payload; a resolved promise does not mean
the operation succeeded.
Response types are deliberately loose
Every field on every response type is optional. The package does not validate responses at runtime, and EuPago's payload shapes vary between API generations and are not fully documented, so a confident type would be a lie that fails at the point of use instead of the point of parsing.
The recommended approach is to validate what you actually depend on, at the boundary, with whatever schema library you already use:
import { z } from "zod";
const multibancoSchema = z.object({
referencia: z.coerce.string(),
entidade: z.coerce.string(),
});
const parsed = multibancoSchema.safeParse(
await eupago.MultibancoReference({ id, valor }),
);
if (!parsed.success) {
// log and degrade — don't assume the fields are there
}(z.coerce.string() is not accidental: some numeric-looking fields come back as
numbers in some responses and strings in others.)
Payment confirmation and webhooks
This package does not handle webhooks, and that gap matters for anyone integrating it.
Creating a Multibanco reference tells you the reference exists — not that it was ever paid. EuPago notifies payment server-to-server via a callback URL you configure in your EuPago account, retried on a schedule if your endpoint fails. Handling that callback — verifying it, parsing it, making it idempotent — is currently your responsibility.
Until that lands here, the two options are:
- Receive EuPago's callback yourself (recommended). Treat the body as untrusted input and validate it; expect retries, so make the handler idempotent.
- Poll with
AdvancedSearchorReferencesByStatus. Simpler, but it costs latency and API calls, and it does not scale.
The browser redirect URLs (success/fail/back) are not confirmation.
A customer can navigate to them without paying. Use them for user experience
only, never to mark an order as paid.
Coverage
Implemented and verified against EuPago's sandbox:
| Method | Status |
| ----------------------------------------------------------- | ----------------------------- |
| MultibancoReference, MultibancoReferenceWithDeadline | ✅ verified |
| Mbway, CreditCard | ✅ verified |
| Payouts, PayoutsTransactions, Transactions | ✅ verified |
| ReferencesByStatus, AdvancedSearch, RefundInformation | ✅ verified |
| Refund, TRIDInformation | ⚠️ endpoint correct, unproven |
Not implemented — EuPago documents these, this package does not cover them: Payshop, Paysafecard, Direct Debit, Split Payments, Apple Pay, Google Pay, Openbank, EuroPix, Floa.
A note on the Node requirement
engines declares >=24 and the build targets Node 24, matching the runtime
this package was written for. The source itself only needs Node 18 features
(global fetch, AbortSignal.timeout), so if you need to support an older
runtime, lowering target in tsup.config.ts and engines in package.json
is the only change required.
Known limitations
RefundandTRIDInformationhave never completed successfully. The endpoint and auth are confirmed correct — they return well-formedTRANSACTION_ID_INVALIDresponses rather than routing or auth errors — but obtaining a validtridis unresolved. Neither thetransactionIDreturned when creating a payment (a UUID) nor the numericreferenceis accepted.- No webhook support. See above.
- No runtime response validation. See above.
- Production is derived, not exercised. All verification was done against
the sandbox. The production hosts follow EuPago's documented
sandbox→clientesdomain swap, but no production call has been made through this package. - No retries. A failed request throws; retry policy is left to the caller, since payment creation is not safely idempotent without care.
Contributing
npm install
npm run lint # eslint --fix + prettier --write
npm run lint:check # check only, for CI
npm run typecheck # tsc --noEmit
npm test # vitest — no network access
npm run build # .d.ts via tsc, ESM + CJS via tsupTests
npm test runs the automated suite. It never touches the network: fetch is
injected as a stub that records the URL, method, headers and body of every
request, which lets the tests assert exactly what would be sent to EuPago. It
covers config and URL resolution, the HTTP layer (query strings, timeouts, error
mapping), token caching and de-duplication, and the request each method builds.
Smoke-testing against the sandbox
scripts/sandbox.ts is a manual runner that makes real calls. It is not part of
the test suite and needs real sandbox credentials.
npm run sandbox # list the available cases
npm run sandbox -- mbway # run oneIt reads credentials from the environment, loading a local .env if present:
| Variable | Used by |
| ------------------------------------------------------------ | ------------------------------ |
| EUPAGO_API_KEY, EUPAGO_CLIENT_ID, EUPAGO_CLIENT_SECRET | all cases |
| EUPAGO_TEST_EMAIL | creditcard, mbway |
| EUPAGO_TEST_PHONE | mbway |
| EUPAGO_TEST_TRID | tridInformation, refund |
| EUPAGO_TEST_REFUND_ID | refundInformation (optional) |
| EUPAGO_SUCCESS_URL / EUPAGO_FAIL_URL / EUPAGO_BACK_URL | creditcard (optional) |
These belong to the script, not the library — the library reads no environment variables.
Releasing
prepack runs lint:check, typecheck, test and build, so publishing with
a failing test is not possible. It is prepack rather than prepublishOnly
because that is the hook both npm publish and yarn publish reliably run.
npm version patch
npm publish
git push --follow-tagsLicense
UNLICENSED. Copyright Nomalism.
