Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ The official Node.js SDK for [Checkout.com](https://www.checkout.com) payment ga

> **⚠️ Important:** Each Checkout.com account has its own unique base URL prefix. You must configure this prefix when initializing the SDK to connect to your specific account. Find your unique prefix in the [Dashboard → Developers → Overview](https://dashboard.checkout.com/developers). See [Base URL Configuration](#base-url-configuration-account-specific) for details.

> **⚠️ Deprecation Notice:** Initializing the SDK without the `subdomain` parameter is **deprecated** and will be removed in a future major version. Please ensure you provide your account-specific subdomain to avoid disruption when upgrading.
> **⚠️ Breaking change in the next major release:** Initializing the SDK without the `subdomain` parameter used to emit a deprecation warning. It now throws. You must either set `subdomain`, or explicitly opt out with `useLegacyDomain: true`, which is itself deprecated and exists only for emergencies. See [Legacy domain (emergency use only)](#legacy-domain-emergency-use-only).

### Subdomain value

Requests must be made through your merchant-specific subdomain (MSSD): the first 8 characters of your client ID (excluding `cli_`). For example, if your client ID is `cli_vkuhvk4vjn2edkps7dfsq6emqm`, your subdomain is `vkuhvk4v`, and the SDK sends requests to `https://vkuhvk4v.api.checkout.com`. Private Link merchants use their `pl-` prefixed subdomain (for example `pl-vkuhvk4v`), which the SDK also accepts. See [Base URLs](https://api-reference.checkout.com/#section/Base-URLs) and [API endpoints](https://www.checkout.com/docs/developer-resources/api/api-endpoints) for further details, and for where to find your unique client ID.

# :rocket: Install

Expand Down Expand Up @@ -166,7 +170,9 @@ const cko = new Checkout(null, {

### Important Notes

> **⚠️ Subdomain is always required:** The `subdomain` option must be passed explicitly when initializing the SDK. It cannot be set via environment variables. Find your unique prefix in [Dashboard → Developers → Overview](https://dashboard.checkout.com/developers).
> **⚠️ Subdomain is always required:** The `subdomain` option must be passed explicitly when initializing the SDK. It cannot be set via environment variables. Find your unique prefix in [Dashboard → Developers → Overview](https://dashboard.checkout.com/developers). Initialization throws a `ValueError` if neither `subdomain` nor `useLegacyDomain` is set, or if both are.

> A custom `host` replaces the base URL outright, so neither option is required when you pass one. Previous (ABC) keys predate merchant-specific subdomains and are exempt.

## Set custom config
Besides the authentication, you also have the option to configure some extra elements about the SDK
Expand Down Expand Up @@ -499,6 +505,20 @@ You can see examples of how to use the SDK for every endpoint documented in our

---

## Legacy domain (emergency use only)

> :warning: **Only use if merchant specific sub domains are causing issues.** Connecting through your merchant-specific subdomain (see [Subdomain value](#subdomain-value)) is the supported way of using the Checkout.com API, and non-subdomain usage will be deprecated.

If, in exceptional circumstances, you cannot use your merchant-specific subdomain, you can explicitly opt out with `useLegacyDomain`:

```js
const cko = new Checkout('sk_...', {
useLegacyDomain: true // deprecated, emergency fallback only
});
```

This routes requests to `api.checkout.com` (or `api.sandbox.checkout.com`) and `access.checkout.com` (or `access.sandbox.checkout.com`). The option is marked `@deprecated` in the type definitions, so editors and `tsc` will flag it. Exactly one of `subdomain` or `useLegacyDomain` must be set: initialization throws a `ValueError` if both, or neither, are. Passing a custom `host` is a third route that bypasses this requirement entirely, since it replaces the base URL outright.

## Contributing

We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get started.
Expand All @@ -512,3 +532,4 @@ MIT License - see [LICENSE](LICENSE) for details.
- 📧 Email: [support@checkout.com](mailto:support@checkout.com)
- 📚 Documentation: [https://api-reference.checkout.com/](https://api-reference.checkout.com/)
- 💬 Community: [GitHub Discussions](https://github.com/checkout/checkout-sdk-node/discussions)

34 changes: 17 additions & 17 deletions src/EnvironmentSubdomain.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import Environment from './Environment.js';
import { ValueError } from './services/errors.js';

export default class EnvironmentSubdomain {
constructor(environment, subdomain) {
Expand All @@ -25,29 +26,28 @@ export default class EnvironmentSubdomain {
}

/**
* Applies subdomain transformation to any given URL.
* If the subdomain is valid (alphanumeric pattern), prepends it to the host.
* Otherwise, returns the original URL unchanged.
*
* Applies subdomain transformation to any given URL by prepending the subdomain to the host.
*
* @param {string} originalUrl - the original URL to transform
* @param {string} subdomain - the subdomain to prepend
* @return {string} the transformed URL with subdomain, or original URL if subdomain is invalid
* @param {string} subdomain - the subdomain to prepend
* @return {string} the transformed URL with subdomain
* @throws {ValueError} if the subdomain is not a valid merchant-specific subdomain
*/
static createUrlWithSubdomain(originalUrl, subdomain) {
if (!EnvironmentSubdomain.isValidSubdomain(subdomain)) {
return originalUrl;
throw new ValueError(
'invalid environment subdomain - provide your merchant-specific subdomain, ' +
'typically your client ID excluding the cli_ prefix, see ' +
'https://api-reference.checkout.com/#section/Base-URLs'
);
}

try {
const url = new URL(originalUrl);
const newHost = subdomain + '.' + url.host;
url.host = newHost;
const result = url.toString().trim();
// Only remove trailing slash if the URL ends with just a slash
return result.endsWith('/') ? result.slice(0, -1) : result;
} catch {
return originalUrl;
}
const url = new URL(originalUrl);
const newHost = subdomain + '.' + url.host;
url.host = newHost;
const result = url.toString().trim();
// Only remove trailing slash if the URL ends with just a slash
return result.endsWith('/') ? result.slice(0, -1) : result;
}

/**
Expand Down
115 changes: 86 additions & 29 deletions src/auth-builder.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as CONFIG from './config.js';
import Environment from './Environment.js';
import EnvironmentSubdomain from './EnvironmentSubdomain.js';
import { ValueError } from './services/errors.js';

/**
* Builds authentication configuration based on keys and options
Expand Down Expand Up @@ -102,21 +103,11 @@ export class AuthBuilder {
const isLive = this.determineEnvironment(key, options);
const environment = isLive ? Environment.live() : Environment.sandbox();

// Create EnvironmentSubdomain if subdomain provided and valid
const environmentSubdomain =
options?.subdomain && EnvironmentSubdomain.isValidSubdomain(options.subdomain)
? new EnvironmentSubdomain(environment, options.subdomain)
: null;

// Emit deprecation warning if subdomain is not provided
if (!environmentSubdomain) {
console.warn(
'[DEPRECATION WARNING] Initializing Checkout SDK without a subdomain is deprecated and will be removed in a future version. ' +
'Please provide your account-specific subdomain using the "subdomain" option. ' +
'You can find your subdomain in Dashboard → Developers → Overview. ' +
'Example: new Checkout(key, { subdomain: "your-prefix" })'
);
}
this.validateDomainOptions(key, options);

const environmentSubdomain = options?.subdomain
? new EnvironmentSubdomain(environment, options.subdomain)
: null;

// Determine host URL
const host = environmentSubdomain
Expand All @@ -132,20 +123,15 @@ export class AuthBuilder {
static setupCustomHost(options) {
const isLive = !options.host.includes('sandbox');
const environment = isLive ? Environment.live() : Environment.sandbox();
const environmentSubdomain =
options?.subdomain && EnvironmentSubdomain.isValidSubdomain(options.subdomain)
? new EnvironmentSubdomain(environment, options.subdomain)
: null;

// Emit deprecation warning if subdomain is not provided with custom host
if (!environmentSubdomain) {
console.warn(
'[DEPRECATION WARNING] Initializing Checkout SDK without a subdomain is deprecated and will be removed in a future version. ' +
'Please provide your account-specific subdomain using the "subdomain" option. ' +
'You can find your subdomain in Dashboard → Developers → Overview. ' +
'Example: new Checkout(key, { host: "your-host", subdomain: "your-prefix" })'
);
}

// A custom host replaces the base URL outright, so the merchant has already said
// where requests go and neither option is required here. A subdomain that is provided
// anyway still has to be well formed, rather than being silently dropped.
this.validateSubdomainFormat(options?.subdomain);

const environmentSubdomain = options?.subdomain
? new EnvironmentSubdomain(environment, options.subdomain)
: null;

return {
host: options.host,
Expand All @@ -154,6 +140,77 @@ export class AuthBuilder {
};
}

/**
* The merchant-specific subdomain is mandatory. Callers must either set `subdomain`, or
* opt out explicitly with `useLegacyDomain: true`, which keeps requests on the shared
* hosts (api.checkout.com and access.checkout.com, or their sandbox equivalents).
*
* `useLegacyDomain` is deprecated from its first release: it exists for the rare case
* where the subdomain cannot be used, and will be removed.
*
* The Previous (ABC) platform predates merchant-specific subdomains, so keys of that
* shape are exempt.
*
* @throws {ValueError} if both options are set, if neither is set, or if the subdomain is
* not a valid merchant-specific subdomain
*/
static validateDomainOptions(key, options) {
const subdomain = options?.subdomain;
const useLegacyDomain = options?.useLegacyDomain === true;

if (subdomain && useLegacyDomain) {
throw new ValueError(
'subdomain and useLegacyDomain cannot both be set - provide only your ' +
'merchant-specific subdomain'
);
}

this.validateSubdomainFormat(subdomain);

// The Previous (ABC) exemption is inferred from the secret-key shape: only keys
// matching PREVIOUS_SECRET_KEY_REGEX are exempt; NAS keys and OAuth are not.
if (!subdomain && !useLegacyDomain && !this.isPreviousPlatform(key, options)) {
throw new ValueError(
'subdomain is required - provide your merchant-specific subdomain (the first 8 ' +
'characters of your client ID, see ' +
'https://api-reference.checkout.com/#section/Base-URLs), or set ' +
'useLegacyDomain: true to opt out only if merchant specific sub domains are ' +
'causing issues'
);
}
}

/**
* A subdomain that is set at all must be a valid merchant-specific subdomain. Invalid values
* used to be dropped back to the shared host without a word.
*
* @throws {ValueError} if the subdomain is set and malformed
*/
static validateSubdomainFormat(subdomain) {
if (subdomain && !EnvironmentSubdomain.isValidSubdomain(subdomain)) {
throw new ValueError(
'invalid environment subdomain - provide your merchant-specific subdomain, the ' +
'first 8 characters of your client ID (see ' +
'https://api-reference.checkout.com/#section/Base-URLs)'
);
}
}

/**
* Whether these credentials belong to the Previous (ABC) platform, which predates
* merchant-specific subdomains and is therefore exempt from requiring one.
*/
static isPreviousPlatform(key, options) {
if (options?.client || process.env.CKO_SECRET) {
return false;
}
const authKey = key || process.env.CKO_SECRET_KEY || '';
const cleanKey = authKey.startsWith('Bearer')
? authKey.replace('Bearer', '').trim()
: authKey;
return CONFIG.PREVIOUS_SECRET_KEY_REGEX.test(cleanKey);
}

/**
* Determine if environment is live or sandbox
*/
Expand Down
3 changes: 3 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export const ETAG_HEADER = 'etag';
export const DEFAULT_TIMEOUT = 15000;

export const MBC_LIVE_SECRET_KEY_REGEX = /^sk_?(\w{8})-(\w{4})-(\w{4})-(\w{4})-(\w{12})$/;
// Previous (ABC) secret keys, live and sandbox. Used to exempt that platform from the
// mandatory merchant-specific subdomain, which it predates.
export const PREVIOUS_SECRET_KEY_REGEX = /^sk_(test_)?(\w{8})-(\w{4})-(\w{4})-(\w{4})-(\w{12})$/;
export const NAS_LIVE_SECRET_KEY_REGEX = /^sk_?[a-z2-7]{26}[a-z2-7*#$=]$/;
export const NAS_SANDBOX_SECRET_KEY_REGEX = /^sk_sbox_?[a-z2-7]{26}[a-z2-7*#$=]$/;
export const NAS_LIVE_PUBLIC_KEY_REGEX = /^pk_?[a-z2-7]{26}[a-z2-7*#$=]$/;
Expand Down
5 changes: 4 additions & 1 deletion test/account-updater/account-updater-it.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ const cko = new Checkout(process.env.CHECKOUT_DEFAULT_OAUTH_CLIENT_SECRET, {
client: process.env.CHECKOUT_DEFAULT_OAUTH_CLIENT_ID,
scope: ['vault:real-time-account-updater'],
environment: 'sandbox',
subdomain: process.env.CHECKOUT_MERCHANT_SUBDOMAIN,
// The sandbox OAuth clients are not provisioned for the merchant-specific
// subdomain, so the token request would come back invalid_client. Opting out
// explicitly until they are.
useLegacyDomain: true,
});

describe('Integration::AccountUpdater', () => {
Expand Down
5 changes: 4 additions & 1 deletion test/agentic-commerce/agentic-commerce-it.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import Checkout from '../../src/Checkout.js';
const cko = new Checkout(process.env.CHECKOUT_DEFAULT_SECRET_KEY, {
pk: process.env.CHECKOUT_DEFAULT_PUBLIC_KEY,
environment: 'sandbox',
subdomain: process.env.CHECKOUT_MERCHANT_SUBDOMAIN,
// The sandbox OAuth clients are not provisioned for the merchant-specific
// subdomain, so the token request would come back invalid_client. Opting out
// explicitly until they are.
useLegacyDomain: true,
});

describe.skip('Integration::AgenticCommerce', () => {
Expand Down
5 changes: 4 additions & 1 deletion test/apple-pay/apple-pay-it.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ const cko = new Checkout(process.env.CHECKOUT_DEFAULT_OAUTH_CLIENT_SECRET, {
client: process.env.CHECKOUT_DEFAULT_OAUTH_CLIENT_ID,
scope: ['vault:apme-enrollment'],
environment: 'sandbox',
subdomain: process.env.CHECKOUT_MERCHANT_SUBDOMAIN,
// The sandbox OAuth clients are not provisioned for the merchant-specific
// subdomain, so the token request would come back invalid_client. Opting out
// explicitly until they are.
useLegacyDomain: true,
});

describe('Integration::Apple-Pay', () => {
Expand Down
5 changes: 4 additions & 1 deletion test/balances/balances-it.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ const cko = new Checkout(process.env.CHECKOUT_DEFAULT_OAUTH_CLIENT_SECRET, {
client: process.env.CHECKOUT_DEFAULT_OAUTH_CLIENT_ID,
scope: ['balances'],
environment: 'sandbox',
subdomain: process.env.CHECKOUT_MERCHANT_SUBDOMAIN,
// The sandbox OAuth clients are not provisioned for the merchant-specific
// subdomain, so the token request would come back invalid_client. Opting out
// explicitly until they are.
useLegacyDomain: true,
});

describe('Integration::Balances', () => {
Expand Down
5 changes: 4 additions & 1 deletion test/card-metadata/card-metadata-it.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ const cko = new Checkout(process.env.CHECKOUT_DEFAULT_OAUTH_CLIENT_SECRET, {
client: process.env.CHECKOUT_DEFAULT_OAUTH_CLIENT_ID,
scope: ['vault:card-metadata'],
environment: 'sandbox',
subdomain: process.env.CHECKOUT_MERCHANT_SUBDOMAIN,
// The sandbox OAuth clients are not provisioned for the merchant-specific
// subdomain, so the token request would come back invalid_client. Opting out
// explicitly until they are.
useLegacyDomain: true,
});

describe('Integration::CardMetadata', () => {
Expand Down
5 changes: 4 additions & 1 deletion test/compliance-requests/compliance-requests-it.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import Checkout from '../../src/Checkout.js';
const cko = new Checkout(process.env.CHECKOUT_DEFAULT_SECRET_KEY, {
pk: process.env.CHECKOUT_DEFAULT_PUBLIC_KEY,
environment: 'sandbox',
subdomain: process.env.CHECKOUT_MERCHANT_SUBDOMAIN,
// The sandbox OAuth clients are not provisioned for the merchant-specific
// subdomain, so the token request would come back invalid_client. Opting out
// explicitly until they are.
useLegacyDomain: true,
});

describe.skip('Integration::ComplianceRequests', () => {
Expand Down
46 changes: 31 additions & 15 deletions test/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,27 +173,43 @@ describe('NAS oAuth', () => {
expect(cko.config.agent).to.be.undefined;
});

it('should initialize with oAuth credentials with bad subdomain', () => {
const cko = new Checkout('2p7YQ37fHiRr8O6lQAikl8enICesB1dvAJrpmE2nZfEOpxzE-', {
client: 'ack_vvzhoai466su3j3vbxb47ts5oe',
scope: ['gateway'],
environment: 'sandbox',
subdomain: ' '
});
expect(cko).to.be.instanceOf(Checkout);
expect(cko.config.client).to.equal('ack_vvzhoai466su3j3vbxb47ts5oe');
expect(cko.config.host).to.equal('https://api.sandbox.checkout.com');
expect(cko.config.scope[0]).to.equal('gateway');
expect(cko.config.secret).to.equal('2p7YQ37fHiRr8O6lQAikl8enICesB1dvAJrpmE2nZfEOpxzE-');
expect(cko.config.agent).to.be.undefined;
it('should fail with a bad subdomain', () => {
expect(
() =>
new Checkout('2p7YQ37fHiRr8O6lQAikl8enICesB1dvAJrpmE2nZfEOpxzE-', {
client: 'ack_vvzhoai466su3j3vbxb47ts5oe',
scope: ['gateway'],
environment: 'sandbox',
subdomain: ' '
})
).to.throw('invalid environment subdomain');
});

it('should fail with an empty subdomain and no legacy-domain opt-out', () => {
expect(
() =>
new Checkout('2p7YQ37fHiRr8O6lQAikl8enICesB1dvAJrpmE2nZfEOpxzE-', {
client: 'ack_vvzhoai466su3j3vbxb47ts5oe',
scope: ['gateway'],
environment: 'sandbox',
subdomain: ''
})
).to.throw('subdomain is required');
});

it('should not exempt a NAS-shaped secret key from the subdomain requirement', () => {
// Only Previous (ABC) keys are exempt; NAS keys still require subdomain or opt-out
expect(() => new Checkout('sk_sbox_fghjovernsi764jybiuogokg7xz')).to.throw(
'subdomain is required'
);
});

it('should initialize with oAuth credentials with subdomain empty', () => {
it('should initialize with oAuth credentials and the legacy domain opt-out', () => {
const cko = new Checkout('2p7YQ37fHiRr8O6lQAikl8enICesB1dvAJrpmE2nZfEOpxzE-', {
client: 'ack_vvzhoai466su3j3vbxb47ts5oe',
scope: ['gateway'],
environment: 'sandbox',
subdomain: ''
useLegacyDomain: true
});
expect(cko).to.be.instanceOf(Checkout);
expect(cko.config.client).to.equal('ack_vvzhoai466su3j3vbxb47ts5oe');
Expand Down
Loading
Loading