{"_id":"@dominaite/merchant-sdk","_rev":"5-f9165b5df7ef50c2e6ca4450fa0f98ac","name":"@dominaite/merchant-sdk","dist-tags":{"latest":"0.4.0"},"versions":{"0.1.0":{"name":"@dominaite/merchant-sdk","version":"0.1.0","license":"UNLICENSED","_id":"@dominaite/merchant-sdk@0.1.0","maintainers":[{"name":"0ximu","email":"ykangalov@dominaite.com"}],"dist":{"shasum":"788e9c94c4fe92625edbdf9857d42cce320450d9","tarball":"https://registry.npmjs.org/@dominaite/merchant-sdk/-/merchant-sdk-0.1.0.tgz","fileCount":19,"integrity":"sha512-5VuaOFJmoeMX/Lat83OUZtHpztAWXkBgL5w+Ey1usRRxxgSjKjglYcbBMn5djdQ5iyLXZ0Xv+TnMEdffm476MQ==","signatures":[{"sig":"MEQCIB3eJjv1jo3Cud50rY0wG6gCfxWyiWrQMCenwjfqF7S4AiAXER9LQ9H5DgoVLpt0nco9rPODc7RGhVoIPSChEC9qrw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":47786},"main":"./dist/cjs/index.js","type":"module","types":"./dist/types/index.d.ts","module":"./dist/esm/index.js","engines":{"node":">=20.0.0"},"exports":{".":{"types":"./dist/types/index.d.ts","import":"./dist/esm/index.js","require":"./dist/cjs/index.js"},"./package.json":"./package.json"},"gitHead":"3b36b14e5f8d59e06c27f72b0e284cb955f4ea56","scripts":{"test":"npm run build && node --test test/","build":"npm run clean && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && tsc -p tsconfig.types.json && node scripts/write-dist-package-json.mjs","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"","prepare":"npm run build"},"_npmUser":{"name":"0ximu","email":"ykangalov@dominaite.com"},"_npmVersion":"10.8.2","description":"Server-side Node.js client for the Dominaite merchant API: create hosted checkout sessions from your own backend.","directories":{},"_nodeVersion":"20.19.5","_hasShrinkwrap":false,"devDependencies":{"typescript":"^5.6.0","@types/node":"^20.14.0"},"_npmOperationalInternal":{"tmp":"tmp/merchant-sdk_0.1.0_1787213355520_0.348670456100175","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@dominaite/merchant-sdk","version":"0.1.1","license":"MIT","_id":"@dominaite/merchant-sdk@0.1.1","maintainers":[{"name":"0ximu","email":"ykangalov@dominaite.com"}],"dist":{"shasum":"602a54c2976411de4d736eaeeba65ffdc361de9d","tarball":"https://registry.npmjs.org/@dominaite/merchant-sdk/-/merchant-sdk-0.1.1.tgz","fileCount":20,"integrity":"sha512-UF/HZhgcqf7jm2P1a/oNy4RWR1nM/RPSsFecDG2xC8LldIze9tpyjZ0WAMVw2CqN6fX7xxCS8Vak1vEdZxVAmw==","signatures":[{"sig":"MEYCIQDPJFqVziLOWKWBo+2dMJEEicUDwStNPZyBnoZLne8BUQIhAOoFdg6ebUQCrli5N6SYc47xiKjDF7XDsGZB2X7aH9mJ","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":48843},"main":"./dist/cjs/index.js","type":"module","types":"./dist/types/index.d.ts","module":"./dist/esm/index.js","engines":{"node":">=20.0.0"},"exports":{".":{"types":"./dist/types/index.d.ts","import":"./dist/esm/index.js","require":"./dist/cjs/index.js"},"./package.json":"./package.json"},"gitHead":"3b36b14e5f8d59e06c27f72b0e284cb955f4ea56","scripts":{"test":"npm run build && node --test test/","build":"npm run clean && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && tsc -p tsconfig.types.json && node scripts/write-dist-package-json.mjs","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"","prepare":"npm run build"},"_npmUser":{"name":"0ximu","email":"ykangalov@dominaite.com"},"_npmVersion":"10.8.2","description":"Server-side Node.js client for the Dominaite merchant API: create hosted checkout sessions from your own backend.","directories":{},"_nodeVersion":"20.19.5","_hasShrinkwrap":false,"devDependencies":{"typescript":"^5.6.0","@types/node":"^20.14.0"},"_npmOperationalInternal":{"tmp":"tmp/merchant-sdk_0.1.1_1787218878516_0.5402091958064954","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@dominaite/merchant-sdk","version":"0.2.0","license":"MIT","_id":"@dominaite/merchant-sdk@0.2.0","maintainers":[{"name":"0ximu","email":"ykangalov@dominaite.com"}],"dist":{"shasum":"a39897807e7b74afa6dd8f9dbadaddb7eb61b7c8","tarball":"https://registry.npmjs.org/@dominaite/merchant-sdk/-/merchant-sdk-0.2.0.tgz","fileCount":23,"integrity":"sha512-9Jn4+EIp0a54SiAgW+8hKG5SJb2WFKSBOft0C3eW/Su7NTXCXzT6S9ZbvIwz1A+vVWah5jjS+/7MGS95/hk01g==","signatures":[{"sig":"MEQCIDSbArQ0hWW9qRDuXikSDY/w2qdziUEZUkvjBhk/45yzAiB61Qkq7vkgxn7paxFY7wHkaXp3JYqqOKw0XFDIIC1OWg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCIQDrG5eSF18HbBa2GZnc2/XBbSgjJ/txP6YlMZMEpgVfUQIgBJ+tx07Guu/CTOAJqDwh91jIps5sExPtf6jLyZ58LDQ=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":108832},"main":"./dist/cjs/index.js","type":"module","types":"./dist/types/index.d.ts","module":"./dist/esm/index.js","engines":{"node":">=20.0.0"},"exports":{".":{"types":"./dist/types/index.d.ts","import":"./dist/esm/index.js","require":"./dist/cjs/index.js"},"./package.json":"./package.json"},"gitHead":"25b6b1308c2e80c1f8438563c9594e9b32094d0e","scripts":{"test":"npm run build && node --test test/","build":"npm run clean && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && tsc -p tsconfig.types.json && node scripts/write-dist-package-json.mjs","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"","prepare":"npm run build"},"_npmUser":{"name":"0ximu","email":"ykangalov@dominaite.com"},"_npmVersion":"10.8.2","description":"Server-side Node.js client for the Dominaite merchant API: create hosted checkout sessions from your own backend.","directories":{},"_nodeVersion":"20.19.5","_hasShrinkwrap":false,"devDependencies":{"typescript":"^5.6.0","@types/node":"^20.14.0"},"_npmOperationalInternal":{"tmp":"tmp/merchant-sdk_0.2.0_1787473510073_0.8114721603749047","host":"s3://npm-registry-packages-npm-production"}},"0.3.1":{"name":"@dominaite/merchant-sdk","version":"0.3.1","license":"MIT","_id":"@dominaite/merchant-sdk@0.3.1","maintainers":[{"name":"0ximu","email":"ykangalov@dominaite.com"}],"homepage":"https://github.com/dominaite/merchant-sdk-node#readme","bugs":{"url":"https://github.com/dominaite/merchant-sdk-node/issues"},"dist":{"shasum":"581899e90235481a29167e87b8606801bbc7780e","tarball":"https://registry.npmjs.org/@dominaite/merchant-sdk/-/merchant-sdk-0.3.1.tgz","fileCount":32,"integrity":"sha512-eTZCyOPnItZwrwM8LOMhZee3c2jGJjJ2t0BT9wAVSC7ZSIZV14TvIdg/LSlqFEaWeE7eD1jRqrqSs/d1u7p9QQ==","signatures":[{"sig":"MEQCIG3stBxz+E87gG/8LqbGVonwf6OW3MHCsOwxevj/tjEgAiBoBa0HyzuLrYUpZf2Xy/JHeM888TqqTkVQflsOK0Dzgw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@dominaite%2fmerchant-sdk@0.3.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":248418},"main":"./dist/cjs/index.js","type":"module","types":"./dist/types/index.d.ts","module":"./dist/esm/index.js","engines":{"node":">=20.0.0"},"exports":{".":{"types":"./dist/types/index.d.ts","import":"./dist/esm/index.js","require":"./dist/cjs/index.js"},"./package.json":"./package.json"},"gitHead":"b31c61811cb40111b3c480901e0916073d8f2a69","scripts":{"test":"npm run build && node --test test/*.test.mjs test/*.test.cjs","build":"npm run clean && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && tsc -p tsconfig.types.json && node scripts/write-dist-package-json.mjs","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"","prepare":"npm run build"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","approver":{"name":"0ximu","email":"ykangalov@dominaite.com"},"trustedPublisher":{"id":"github","oidcConfigId":"oidc:f14388e7-1062-4fb6-aeca-652e20ffb313"}},"repository":{"url":"git+https://github.com/dominaite/merchant-sdk-node.git","type":"git"},"_npmVersion":"11.20.0","description":"Server-side Node.js client for the Dominaite merchant API: create hosted checkout sessions from your own backend.","directories":{},"_nodeVersion":"24.21.0","_hasShrinkwrap":false,"devDependencies":{"typescript":"^5.6.0","@types/node":"^20.14.0"},"_npmOperationalInternal":{"tmp":"tmp/merchant-sdk_0.3.1_1790456328163_0.09859061159667326","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"_id":"@dominaite/merchant-sdk@0.4.0","bugs":{"url":"https://github.com/dominaite/merchant-sdk-node/issues"},"dist":{"shasum":"5a78a6cd50512dc896786c727c7bcba1c532acf1","tarball":"https://registry.npmjs.org/@dominaite/merchant-sdk/-/merchant-sdk-0.4.0.tgz","integrity":"sha512-4V5MZUlM96u2TvYzd3NYtr/isqtKxksu8DutXT1ysKtvKnLsbLi4VNQaA5EFndlcOQEFk+sA3htvw+JnsmQllA==","fileCount":32,"unpackedSize":252980,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@dominaite%2fmerchant-sdk@0.4.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIAwFe0iM7TQDsXTvRDY+Ub5wt3tjNMnGPwNup+/kTZw6AiEAvfcrkEyWjX6juiT1y7G/M9bIgXBe+j8cpO8cyT6B9+c="}]},"main":"./dist/cjs/index.js","name":"@dominaite/merchant-sdk","type":"module","types":"./dist/types/index.d.ts","module":"./dist/esm/index.js","engines":{"node":">=20.0.0"},"exports":{".":{"types":"./dist/types/index.d.ts","import":"./dist/esm/index.js","require":"./dist/cjs/index.js"},"./package.json":"./package.json"},"gitHead":"ff1322af2310971498f3ff2222f77b058529db5a","license":"MIT","scripts":{"test":"npm run build && node --test test/*.test.mjs test/*.test.cjs","build":"npm run clean && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && tsc -p tsconfig.types.json && node scripts/write-dist-package-json.mjs","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"","prepare":"npm run build"},"version":"0.4.0","_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"f14388e7-1062-4fb6-aeca-652e20ffb313"},"approver":{"name":"0ximu","email":"ykangalov@dominaite.com"}},"homepage":"https://github.com/dominaite/merchant-sdk-node#readme","repository":{"url":"git+https://github.com/dominaite/merchant-sdk-node.git","type":"git"},"_npmVersion":"11.21.0","description":"Server-side Node.js client for the Dominaite merchant API: create hosted checkout sessions from your own backend.","directories":{},"maintainers":[{"name":"0ximu","email":"ykangalov@dominaite.com"}],"_nodeVersion":"24.21.0","devDependencies":{"typescript":"^5.6.0","@types/node":"^20.14.0"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/merchant-sdk_0.4.0_1790917386170_0.24681045912924504"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-20T08:09:15.027Z","modified":"2026-10-02T05:03:06.560Z","0.1.0":"2026-08-20T08:09:15.685Z","0.1.1":"2026-08-20T09:41:18.662Z","0.2.0":"2026-08-23T08:25:10.274Z","0.3.1":"2026-09-26T20:58:48.321Z","0.4.0":"2026-10-02T05:03:06.269Z"},"bugs":{"url":"https://github.com/dominaite/merchant-sdk-node/issues"},"license":"MIT","homepage":"https://github.com/dominaite/merchant-sdk-node#readme","repository":{"url":"git+https://github.com/dominaite/merchant-sdk-node.git","type":"git"},"description":"Server-side Node.js client for the Dominaite merchant API: create hosted checkout sessions from your own backend.","maintainers":[{"name":"0ximu","email":"ykangalov@dominaite.com"}],"readme":"# dominaite-node\n\nServer-side Node.js client for the Dominaite merchant API. One call from your backend opens a\nhosted checkout session; a two-line script tag renders the payment widget on your page. Card\ndetails go straight from your customer's browser into the payment widget - they never touch\nyour server, which keeps your PCI scope minimal (SAQ A).\n\nNode 20 or newer. Zero runtime dependencies: `node:crypto` and the built-in `fetch`. Ships ESM,\nCommonJS, and TypeScript types.\n\n## Install\n\nThe package name is `@dominaite/merchant-sdk` (scope and name verified free on npm, 2026-08-17;\npublishing needs the `dominaite` npm org created first). It is **not published yet** - until it\nis, install from a local checkout:\n\n```bash\nnpm install /path/to/dominaite-node-sdk\n```\n\nor in `package.json`:\n\n```json\n{ \"dependencies\": { \"@dominaite/merchant-sdk\": \"file:../dominaite-node-sdk\" } }\n```\n\nIf you cloned this repo directly, build it once before using it - `npm install` in your app runs\n`prepack` for you, but a plain in-place checkout does not:\n\n```bash\ncd dominaite-node-sdk\nnpm install\nnpm run build     # emits dist/esm, dist/cjs, dist/types\nnpm test          # builds, then runs the suite (includes the offline signing vector)\n```\n\n## Credentials\n\nYou get two values from the Dominaite dashboard, **Website integration** tab, when you generate an\nAPI key (shown once - store them like passwords):\n\n- `dmk_...` - your API key id. Identifies you; not secret by itself.\n- `dms_...` - your API secret. Server-side only: environment variable or a config file outside the\n  web root. Never in a browser, never in git, never in logs.\n\nEvery request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock on\nNTP - signatures older than 5 minutes are rejected with `TIMESTAMP_OUT_OF_RANGE`.\n\nIf the key has an IP allowlist, calls from anywhere else fail with `IP_NOT_ALLOWED`. The allowlist\nis managed on the same dashboard tab.\n\n## Quickstart (zero to a signed session against dev)\n\nEverything below is copy-paste. It assumes an empty directory and nothing installed.\n\n```bash\nmkdir my-checkout && cd my-checkout\nnpm init -y\nnpm pkg set type=module\nnpm install /path/to/dominaite-node-sdk\n```\n\nSet your credentials and the environment you are pointing at:\n\n```bash\nexport DOMINAITE_KEY_ID=dmk_...      # Website integration tab\nexport DOMINAITE_SECRET=dms_...      # shown once when you generated the key\n# Dev: the payments function app, whose Azure Functions route prefix is /api.\n# Confirm the host for your environment before the first call.\nexport DOMINAITE_BASE_URL=https://func-dom-gw-payments-dev-gwc-01.azurewebsites.net/api\n# Production needs no DOMINAITE_BASE_URL - the SDK defaults to\n# https://api.dominaite.com/payments\n```\n\nPing before your first mint. It is one signed GET that creates nothing, so anything that\nfails here is your credentials, your signing or your clock:\n\n```js\nimport { DominaiteClient } from '@dominaite/merchant-sdk'\n\nconst client = new DominaiteClient({\n  keyId: process.env.DOMINAITE_KEY_ID,\n  secret: process.env.DOMINAITE_SECRET,\n  baseUrl: process.env.DOMINAITE_BASE_URL, // omit in production\n})\n\nconsole.log(await client.ping())\n// { pong: true, merchantId: '...', serverTime: '...', clockSkewSeconds: 0 }\n```\n\nKeep an eye on `clockSkewSeconds`: the gateway rejects requests once it passes 300, so a\nnumber that keeps growing is your cue to fix NTP before payments start failing.\n\n`baseUrl` has to be `https://`. The only exception is a local gateway on `localhost`,\n`127.0.0.1` or `::1`; anything else over plain http would put your key id, timestamp and\nsignature on the wire in the clear, so the constructor throws a `TypeError` instead.\n\n`create-session.mjs`:\n\n```js\nimport {\n  CheckoutRefusedError,\n  DominaiteClient,\n  orderIdempotencyKey,\n  TransportError,\n} from '@dominaite/merchant-sdk'\n\nconst client = new DominaiteClient({\n  keyId: process.env.DOMINAITE_KEY_ID,\n  secret: process.env.DOMINAITE_SECRET,\n  baseUrl: process.env.DOMINAITE_BASE_URL, // omit in production\n})\n\ntry {\n  const session = await client.createCheckoutSession({\n    amount: 2500,                    // minor units: 2500 = 25.00 EUR\n    currency: 'EUR',\n    orderReference: 'order-1042',    // your own order id, shows up in your dashboard\n    // Required. Same order + same amount = same key, so a reload replays this session.\n    idempotencyKey: orderIdempotencyKey({\n      scope: 'checkout',\n      orderId: 'order-1042',\n      amountMinor: 2500,\n      currency: 'EUR',\n    }),\n    customer: {\n      // Pass everything you already know - prefilled fields are hidden from the\n      // payer, so the checkout form stays short.\n      firstName: 'Ana',\n      lastName: 'Kirova',\n      email: 'ana@example.com',\n    },\n    language: 'bg',                  // widget UI language\n    theme: 'dark',\n  })\n\n  // Never log cashierToken, and never log the whole session object either - it\n  // carries the token. Log the fields you actually need to trace an order.\n  console.log({\n    transactionId: session.transactionId,\n    orderId: session.orderId,\n    amount: session.amount,\n    currency: session.currency,\n    expiresAt: session.expiresAt,\n  })\n  // Store session.transactionId against your order, then hand cashierKey +\n  // cashierToken to the page that renders the widget.\n} catch (error) {\n  if (error instanceof CheckoutRefusedError) {\n    // Machine-readable: error.errorCode - codes listed below.\n    console.error('Payment unavailable:', error.errorCode)\n  } else if (error instanceof TransportError) {\n    // Network blip - safe to retry with the same idempotencyKey.\n    console.error('Payment temporarily unavailable')\n  } else {\n    throw error\n  }\n}\n```\n\n```bash\nnode create-session.mjs\n```\n\nThe session carries `transactionId`, `orderId`, `cashierKey`, `cashierToken`, `amount`,\n`currency`, `expiresAt` and `integration` (plus `clientSecret` for card fields, see \"Card fields\"\nbelow). Render the widget with the two cashier fields:\n\n```html\n<div id=\"checkout\"></div>\n<script src=\"https://bp-checkout.dominaite.com/v2/launcher\"\n        data-cashier-key=\"CASHIER_KEY_FROM_SESSION\"\n        data-cashier-token=\"CASHIER_TOKEN_FROM_SESSION\"></script>\n```\n\n`cashierKey` and `cashierToken` are per-payment session values rather than account credentials,\nbut `cashierToken` is what lets a browser drive the payment: keep it out of your logs, your error\nreports and any analytics payload, and HTML-escape both when you template them into the page.\n\nThat covers the paying half: the session call, the script tag, and your domain bound to your\ncheckout by Dominaite during onboarding. The other half is finding out that the money arrived,\nwhich is what the next section is about. The shape of a full integration is:\n\n1. Create a session from your backend.\n2. Render the widget and let the payer pay.\n3. Receive a `payment.succeeded` webhook.\n4. Fulfill the order, keyed off your `orderReference`.\n\nDo not fulfill on the browser redirect back to your site. The payer closing the tab, a flaky\nnetwork or a curious customer editing the return URL all produce the same \"success\" page; only\nthe webhook (or `getStatus`) tells you what actually happened.\n\nThere is a runnable version of the above in `examples/create-session.mjs` in this repo - it mints a\nsession and reads the status back, using the same three environment variables.\n\n### CommonJS\n\n```js\nconst { DominaiteClient } = require('@dominaite/merchant-sdk')\n```\n\nSame API. Node's `require()` of this package resolves to the CJS build.\n\n## Amounts are minor units\n\n`amount` is always an integer in the currency's minor unit: `2500` is 25.00 EUR. The SDK rejects\nfloats and non-positive values before anything reaches the network. The amount is locked\nserver-side - what you pass here is what gets charged; nothing in the browser can change it.\n\nHow many digits the minor unit has depends on the currency, as the gateway counts it. Most have\ntwo, but not all:\n\n| Exponent | Currencies | `25` of it in minor units |\n|---|---|---|\n| 2 | EUR, USD, GBP, CAD, AUD, CHF, BGN, RON, PLN, CZK, SEK, DKK, NOK | `2500` |\n| 0 | JPY, HUF | `25` |\n| 3 | BHD, KWD | `25000` |\n\n**HUF is whole forints.** ISO 4217 gives the forint two decimals, but the gateway charges whole\nforints: 2500 HUF is `amount: 2500`, not `250000`. Converting with the ISO exponent charges 100\ntimes too much. ISK, KRW, OMR, JOD and TND are not supported by the helper, because ISO and the\ngateway disagree on them.\n\n`toMinorUnits` does the conversion from the decimal string your price list or database already\nholds, without floating point:\n\n```js\nimport { toMinorUnits } from '@dominaite/merchant-sdk'\n\ntoMinorUnits('25.00', 'EUR')   // 2500\ntoMinorUnits('0.30', 'EUR')    // 30\ntoMinorUnits('2500', 'JPY')    // 2500\ntoMinorUnits('2500', 'HUF')    // 2500 (whole forints)\ntoMinorUnits('1.5', 'BHD')     // 1500\n\ntoMinorUnits(0.1 + 0.2, 'EUR') // TypeError: pass a string, floats cannot hold prices exactly\ntoMinorUnits('25.001', 'EUR')  // TypeError: EUR has 2 decimal places\ntoMinorUnits('25.000', 'EUR')  // TypeError too: extra zeros are not ignored\ntoMinorUnits('25.00', 'XYZ')   // TypeError: unknown currency, never a guessed default\n```\n\nIt throws `TypeError` rather than rounding or guessing: a number instead of a string, more\nfractional digits than the currency has (zeros included), an unsupported currency, or a currency\nmissing from `CURRENCY_EXPONENTS`.\n\n## Idempotency keys\n\nEvery `createCheckoutSession` and `chargePaymentMethod` call needs an `idempotencyKey`. There is no\ndefault: a missing or empty key throws `TypeError` before anything is sent. A key the SDK made up\nwould be different on every attempt, which is the double payment the key exists to stop.\n\nDerive the key from the order instead of generating one:\n\n```js\nimport { orderIdempotencyKey } from '@dominaite/merchant-sdk'\n\norderIdempotencyKey({ scope: 'checkout', orderId: 'order-1042', amountMinor: 2500, currency: 'eur' })\n// 'checkout-order-1042-2500-EUR'\n```\n\n- **Same order, same amount, same key.** A page reload, the back button or a retry after a timeout\n  sends the key again and replays the same session instead of opening a second payment.\n- **Changed amount, new key.** If the basket changes, the amount (or currency) in the key changes\n  with it and you get a fresh session. Reusing the old key with a new amount would be refused\n  with `IDEMPOTENCY_KEY_REUSED`.\n- `scope` keeps different kinds of request for one order apart (`checkout`, `charge`, ...).\n- A key is 1 to 100 visible ASCII characters (`0x21` to `0x7E`): no spaces, no accented or\n  Cyrillic letters. The helper throws `TypeError` if `scope` and `orderId` break that (slug the\n  order id first), or if an input is malformed. `amountMinor` is the integer you send as `amount`.\n\n## Retries and double-charges\n\nRetrying with the same key never opens a second payment - on a timeout, retry with the same key\nrather than generating a new one.\n\n`createCheckoutSessionWithRetry` does that for you: it sends your key unchanged on every attempt,\nretrying `TransportError` (network failures and 5xx, including `MERCHANT_API_UNAVAILABLE`) and\n`PAYMENT_PROCESSING_UNAVAILABLE`, whether it arrives as a 503 or as an HTTP 200 refusal. Other\nrefusals and authentication failures are not retried - they will not change.\n\n```js\nconst session = await client.createCheckoutSessionWithRetry(\n  {\n    amount: 2500,\n    currency: 'EUR',\n    orderReference: 'order-1042',\n    idempotencyKey: orderIdempotencyKey({\n      scope: 'checkout', orderId: 'order-1042', amountMinor: 2500, currency: 'EUR',\n    }),\n  },\n  { attempts: 3, baseDelayMs: 500 },   // both optional; delay doubles per attempt\n)\n```\n\nWhat a retry (or a page reload) of the same key gets back depends on where the first attempt got:\n\n- **It never reached the gateway.** The retry is an ordinary create and you get a new session.\n- **It reached the gateway and the session is still open and unexpired.** A clean replay (same\n  amount, currency and `saveCard`) returns the **original** session, `success: true`, with the\n  same `transactionId`, `cashierKey` and `cashierToken`. This is how a reload or a lost response\n  gets the payer back into the checkout they already had.\n- **The payment has moved on, or the body changed.** The retry comes back HTTP 200 with\n  `success: false` and a replay code - `DUPLICATE_REQUEST`, `ALREADY_PROCESSED`,\n  `PRIOR_ATTEMPT_FAILED` or `IDEMPOTENCY_KEY_REUSED` - which this SDK throws as\n  `CheckoutRefusedError`.\n\nSo the timeout path can get either a session or a refusal. When the refusal names a\n`transactionId`, read it back with `getStatus` to find out what the first attempt did (see\n\"Recovering from a replay refusal\" below).\n\n## Sessions expire\n\nA session is valid for 2 hours. If the payer comes back later, create a new session - and from a\nfew minutes past `expiresAt` you can re-POST the **same** idempotency key to get one, so keep the\norder-derived key for the life of the order (see \"Recovering from a replay refusal\").\n\n## Card fields\n\nInstead of the hosted widget, you can render card fields inside your own checkout page. Card\nfields are enabled per merchant on request: ask Dominaite support to switch them on. Until then a\nsession with `integration: 'fields'` is rejected with a 400 (`INVALID_SELECTION` on\n`integration`).\n\nPass `integration: 'fields'` when you create the session. Leave it out (or pass `'widget'`) for\nthe hosted widget. It is part of the idempotency identity, so a replay of the same key with a\ndifferent `integration` is refused with `IDEMPOTENCY_KEY_REUSED`.\n\n```js\nconst session = await client.createCheckoutSession({\n  amount: 8440,\n  currency: 'EUR',\n  orderReference: 'order-1042',\n  integration: 'fields',\n  idempotencyKey: orderIdempotencyKey({\n    scope: 'checkout',\n    orderId: 'order-1042',\n    amountMinor: 8440,\n    currency: 'EUR',\n  }),\n})\n// session.integration === 'fields', session.clientSecret is set\n```\n\nHand `transactionId`, `integration`, `cashierKey`, `cashierToken` and `clientSecret` to the\npayer's page, load the drop-in and mount it:\n\n```html\n<div id=\"checkout\"></div>\n<script src=\"https://pay.dominaite.com/v1/checkout.js\"></script>\n<script>\n  const checkout = Dominaite.checkout({\n    transactionId: 'TRANSACTION_ID_FROM_SESSION',\n    integration: 'fields',\n    cashierKey: 'CASHIER_KEY_FROM_SESSION',\n    cashierToken: 'CASHIER_TOKEN_FROM_SESSION',\n    clientSecret: 'CLIENT_SECRET_FROM_SESSION',\n  })\n  checkout.on('success', () => { /* show a \"thank you, confirming\" state */ })\n  checkout.mount('#checkout')\n</script>\n```\n\n`clientSecret` is what lets the browser charge this one session: treat it like `cashierToken`,\nkeep it out of logs and HTML-escape it. A widget session has no `clientSecret`.\n\nThe page saying \"success\" is not proof of payment. Mark the order paid only from the\n`payment.succeeded` webhook or a `getStatus()` read, exactly as with the widget.\n\n## Stored payment methods (recurring)\n\nPass `saveCard: true` when you create a session and, once that payment is approved, the gateway\nkeeps the card on file. You never see the card number or the provider token: `getStatus()` returns\na `storedPaymentMethod` with an opaque `id` (`pm_` + 32 hex characters), the `brand`, the `last4`\nand the expiry, and that `id` is what you charge and revoke with. Store it against your customer.\n(`paymentMethod` on the same status is something else: the gateway's string category of how the\npayer paid, `card`, `wallet` and so on.)\n\n```js\nimport { ChargeError, RevokeError } from '@dominaite/merchant-sdk'\n\nconst session = await client.createCheckoutSession({\n  amount: 2500,\n  currency: 'EUR',\n  orderReference: 'sub-8817-first',\n  idempotencyKey: 'sub-8817-first',\n  saveCard: true,\n})\n// ... the payer completes the hosted checkout ...\nconst status = await client.getStatus(session.transactionId)\nif (status.status === 'succeeded' && status.storedPaymentMethod?.status === 'active') {\n  await db.saveCard(customerId, status.storedPaymentMethod.id) // pm_...\n}\n\n// Later, off-session, no payer present:\ntry {\n  const charge = await client.chargePaymentMethod(paymentMethodId, {\n    amount: 2500,\n    currency: 'EUR',\n    orderReference: 'sub-8817-2026-10',\n    description: 'Monthly plan, October',\n    idempotencyKey: 'sub-8817-2026-10', // derive it from the billing period, never random per attempt\n  })\n\n  switch (charge.status) {\n    case 'succeeded':\n      break\n    case 'pending':\n      // Not terminal. Poll getStatus(charge.transactionId), or wait for the webhook.\n      break\n    case 'failed':\n      // HTTP 402 from the gateway, but not an exception: branch on the class, log the code.\n      // hard              - give up on this card, ask the customer for another one\n      // soft_funds        - insufficient funds, retry later (not in a loop)\n      // soft_sca_required - the issuer wants the customer present: send them through a\n      //                     hosted session with saveCard and charge the new method\n      // soft_other        - transient, one retry later is reasonable\n      handleDecline(charge.declineClass, charge.declineCode)\n      break\n    case 'cancelled':\n      // An authorization voided before capture; no money moved.\n      break\n  }\n} catch (error) {\n  if (error instanceof ChargeError) {\n    switch (error.errorCode) {\n      case 'CHARGE_OUTCOME_UNKNOWN':\n        // 502: the provider gave no verdict, the charge MAY have happened. Never retry\n        // under a new key: poll the transaction the gateway attached instead.\n        await pollUntilSettled(error.charge.transactionId)\n        break\n      case 'DUPLICATE_REQUEST':\n      case 'PAYMENT_METHOD_CHARGES_DISABLED':\n      case 'PAYMENT_PROCESSING_UNAVAILABLE':\n        // Nothing was charged; retry later with the SAME idempotency key.\n        break\n      case 'PAYMENT_METHOD_NOT_ACTIVE':\n        // Revoked or expired: bring the customer back for a hosted session with saveCard.\n        break\n      case 'CHARGE_FAILED':\n        // 502, nothing was charged. error.charge is present when a row exists.\n        break\n      case 'IDEMPOTENCY_KEY_REUSED':\n        // Same key, different body or method: a bug on your side.\n        break\n    }\n  } else {\n    throw error\n  }\n}\n\n// When the customer removes the card:\ntry {\n  await client.revokePaymentMethod(paymentMethodId) // 204, resolves with nothing; 204 again if already revoked\n} catch (error) {\n  if (error instanceof RevokeError && error.errorCode === 'MERCHANT_API_UNAVAILABLE') {\n    // 503: nothing changed, retry later.\n  } else if (error instanceof RevokeError) {\n    // 502 UPSTREAM_CONTRACT_ERROR: the provider refused for good, nothing changed. Contact support with the id.\n  }\n}\n```\n\nA charge is signed exactly like a session and carries an `Idempotency-Key`, so a retry after a\ntimeout with the **same** key never charges the card twice: the gateway replays its first answer,\nHTTP status included. The HTTP status is the contract on this route: 201 (or 200 on a replay)\nresolves with the charge, 402 resolves with the charge too (`status: 'failed'` plus\n`declineClass`), and 409, 422, 502 and 503 throw `ChargeError` with `errorCode`, `httpStatus`,\nthe gateway's message and, when the gateway attached the charge row, `charge` and\n`transactionId`. Only authentication (401/403), an id that is not yours (404, `ApiError`),\nvalidation (400, `ApiError`), rate limiting (429) and network failures keep their generic\nerrors. `declineClass` and `declineCode` are `null` unless the charge was declined; the gateway\nomits them on the wire and the SDK reads absent as null.\n\nRevoking signs an empty key and an empty body, like `getStatus()`. A revoke that fails with\n`RevokeError` changed nothing: `MERCHANT_API_UNAVAILABLE` (503) is retryable,\n`UPSTREAM_CONTRACT_ERROR` (502) is not. After a revoke the status read keeps the\n`storedPaymentMethod` with `status: 'revoked'`, and a charge against it is refused with\n`PAYMENT_METHOD_NOT_ACTIVE`.\n\nThe platform can also retire a card on its own: `status: 'retired'`, with `retiredReason` set to\n`hard_decline`, `chargeback` or `source_sale_reversed` (`STORED_PAYMENT_METHOD_RETIRED_REASONS`).\nA retired card is refused with `PAYMENT_METHOD_NOT_ACTIVE` too and never becomes active again,\nso ask the customer to save a card again. `retiredReason` is `null` on every other card.\n\n## Refunds\n\nRefund a payment in full or in part with `createRefund()`. `transactionId` is the id the checkout\nsession or the charge returned. The amount is minor units of the payment's currency, so convert\nwith `toMinorUnits()`; omit it to refund everything still refundable (the SDK then sends no\n`amount` at all). A refund is always in the payment's currency, and partial refunds add up: the\namount may not exceed what is left after earlier refunds and refunds still in progress.\n\n```js\nimport { ErrorCodes, RefundError, toMinorUnits } from '@dominaite/merchant-sdk'\n\n// 1,500 HUF back on a payment: HUF has no minor unit here, so this is 1500.\nconst refund = await client.createRefund(transactionId, {\n  amount: toMinorUnits('1500', 'HUF'),\n  reason: 'Returned item',                   // optional, at most 500 characters\n  idempotencyKey: `refund-${creditNoteId}`,  // required: derive it from YOUR refund\n})\n// refund.status is 'pending': queued, not done yet.\n\n// Everything still refundable:\nawait client.createRefund(transactionId, { idempotencyKey: `refund-${creditNoteId}` })\n\n// Later, or while waiting for the webhook:\nconst current = await client.getRefund(transactionId, refund.refundId)\nif (current.status === 'succeeded') {\n  // current.amount is what went back to the customer.\n} else if (current.status === 'failed') {\n  // current.amount is null; current.failureCode says why. A new attempt needs a NEW key.\n}\n```\n\n`createRefund()` answers HTTP 202 as soon as the refund is queued. The outcome arrives later:\nread it with `getRefund()` (signed with an empty key and an empty body, like `getStatus()`) or\nwait for `payment.refunded`, which fires once the money has moved. **A refund that fails sends\nno webhook**, so poll `getRefund()` if you need to know about failures.\n\nThe `Idempotency-Key` is signed, exactly like a charge. Replaying the same key never refunds\ntwice: the gateway answers 202 with the same refund as it stands now, so a replay doubles as a\nstatus read. The same key with a different amount, reason or payment is refused with\n`IDEMPOTENCY_KEY_REUSED`.\n\nA refund (`Refund`) carries `refundId` (`re_` + 32 hex characters), `transactionId`, `status`,\n`amount`, `currency`, `failureCode`, `failureMessage` and `completedAt`. The gateway omits null\nfields on the wire and the SDK reads absent as null. `amount` is the amount requested on\n`pending` (null for a full refund), the amount being refunded on `processing` (null until a full\nrefund has been sized), the amount actually refunded on `succeeded`, and always null on `failed`.\n\n`status` is one of `REFUND_STATUSES`: `pending` (queued), `processing` (with the payment\nprovider), `succeeded` or `failed`. The last two are final, and `failed` is final for that key.\nA failed refund is a result, not an exception: `failureCode` is one of `REFUND_FAILURE_CODES`\n(`REFUND_AMOUNT_EXCEEDED`, `PAYMENT_NOT_REFUNDABLE`, `REFUND_FAILED`). Treat a code you do not\nrecognise as `REFUND_FAILED`.\n\nWhen the gateway refuses the request itself, the SDK throws `RefundError` (an `ApiError`) with\n`errorCode`, `httpStatus` and `retryable`:\n\n| `errorCode` | HTTP | What to do |\n|---|---|---|\n| `PAYMENT_NOT_FOUND` | 404 | No card-not-present payment with this id under your account. |\n| `REFUND_NOT_FOUND` | 404 | `getRefund()` only. Right after the 202 the refund may not be picked up yet: poll again for up to 60 seconds. `retryable` is true. |\n| `PAYMENT_NOT_REFUNDABLE` | 422 | Not paid, already fully refunded, or everything left is already being refunded. Nothing was queued; the key is not burnt. |\n| `REFUND_AMOUNT_EXCEEDED` | 422 | More than what is left to refund; the message names the amount left. Nothing was queued; the key is not burnt. |\n| `IDEMPOTENCY_KEY_REUSED` | 422 | The key was first used for a different refund. Use a fresh key. |\n| `DUPLICATE_REQUEST` | 409 | A request with this key is still being processed. Retry with the **same** key after a second, for up to 120 seconds. `retryable` is true. |\n| `IDEMPOTENCY_KEY_REQUIRED` | 400 | The key was missing or too long. |\n\nA 500 means nothing was queued and arrives as `TransportError`: retry with the **same** key.\n\n```js\ntry {\n  await client.createRefund(transactionId, { amount, idempotencyKey })\n} catch (error) {\n  if (error instanceof RefundError && error.errorCode === ErrorCodes.REFUND_AMOUNT_EXCEEDED) {\n    // Ask for a smaller amount; the same key can be used again.\n  } else if (error instanceof RefundError && error.retryable) {\n    // DUPLICATE_REQUEST: try again shortly with the same key.\n  }\n}\n```\n\nOn `payment.refunded`, `data.transactionId` is the refund's own transaction id, `data.amount` is\nthat refund's amount, and `data.originalTransactionId` is the payment it refunds. There is one\nevent per completed refund, partial or full.\n\n## Webhooks\n\nRegister an endpoint in the Dominaite dashboard, **Webhooks** tab: an HTTPS URL, the events you\nwant, and a retry count. The signing secret (`whsec_...`) is shown **once** at creation - store it\nlike your API secret. Regenerating it replaces the old one immediately. You can have up to 25\nactive endpoints.\n\nEvents you can subscribe to: `payment.succeeded`, `payment.failed`, `payment.requires_capture`,\n`payment.cancelled`, `payment.abandoned`, `payment.refunded`, `payment.disputed`. `succeeded` is\nthe only one that means money in hand. In-flight states (`pending`, `processing`) are never\nwebhooked - see the polling subsection below for those.\n\nRecurring billing adds `agreement.activated`, `agreement.past_due`, `agreement.cancelled`,\n`charge.succeeded`, `charge.failed` and `charge.retrying`, on the same envelope and the same retry\nladder. See [Ordering agreement and charge events](#ordering-agreement-and-charge-events).\n\nThe body is flat JSON, with no `success` wrapper to branch on:\n\n```json\n{\n  \"id\": \"7f9c24e5-1d1f-4c0a-9b6c-2f3a4d5e6f70\",\n  \"type\": \"payment.succeeded\",\n  \"apiVersion\": \"2026-09-25\",\n  \"createdAt\": \"2026-08-20T14:00:00Z\",\n  \"data\": {\n    \"transactionId\": \"0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0\",\n    \"status\": \"succeeded\",\n    \"previousStatus\": \"pending\",\n    \"kind\": \"sale\",\n    \"amount\": 8440,\n    \"grossAmount\": 8701,\n    \"surchargeAmount\": 261,\n    \"currency\": \"EUR\",\n    \"originalTransactionId\": null,\n    \"idempotencyKey\": \"order-123\"\n  }\n}\n```\n\nAmounts are minor units. On `payment.*` the `amount` is what you are paid and `grossAmount` is the\ncard movement; on `payment.refunded` the `amount` is what went back to the customer.\n\n`payment.*` data also carries `storedPaymentMethod`: the card a `saveCard` payment stored, the\nsame object as `storedPaymentMethod` on `getStatus()` (`id`, `brand`, `last4`, `expiryMonth`,\n`expiryYear`, `status`, `retiredReason`). It is set on `payment.succeeded` (and\n`payment.requires_capture` for an authorization) when the card was stored together with the\napproval, and null or absent on every other event and whenever no card was saved. It can also be\nnull when a card **was** saved, because a card can be stored after the approval was already\nannounced. The status read is the source of truth: on a `saveCard` payment whose event has no\n`storedPaymentMethod`, call `getStatus()` to pick the card up.\n\n`apiVersion` is the dated version of the payload shapes. A new date means a breaking change to the\nenvelope or a `data` shape; added fields keep the current date. A gateway that predates it does not\nsend it, so the SDK types it as optional.\n\n### Verify first, parse second\n\nEvery delivery carries `X-Webhook-Signature: t={unix_seconds},v1={hex}` - HMAC-SHA256 over\n`\"{t}.{raw_body}\"`, keyed with your endpoint secret. `verifyWebhook` checks it in constant time and\nrejects a timestamp more than 5 minutes off, which is what stops someone replaying a delivery they\ncaptured earlier.\n\n```js\nimport express from 'express'\nimport { parseWebhookEvent, verifyWebhook } from '@dominaite/merchant-sdk'\n\nconst app = express()\n\n// express.raw, not express.json: the signature covers the exact bytes that arrived, and\n// JSON.parse + re-serialize does not reproduce them.\napp.post('/webhooks/dominaite', express.raw({ type: 'application/json' }), (req, res) => {\n  const raw = req.body.toString('utf8')\n\n  if (!verifyWebhook(raw, req.get('X-Webhook-Signature') ?? '', process.env.DOMINAITE_WEBHOOK_SECRET)) {\n    return res.sendStatus(400)\n  }\n\n  const event = parseWebhookEvent(raw)\n  if (alreadyHandled(event.id)) {\n    return res.sendStatus(200)     // duplicate delivery, nothing to do\n  }\n\n  enqueue(event)                   // your queue, your worker, your database transaction\n  res.sendStatus(200)\n})\n```\n\n`verifyWebhook(payload, signatureHeader, secret, toleranceSeconds = 300, nowSeconds?)` returns a\nboolean. Anything an attacker controls - a tampered body, a wrong secret, a stale or future\ntimestamp, a malformed or missing header - comes back `false` rather than throwing. It throws\n`TypeError` only when your own call is wrong (a Buffer instead of a string, an empty secret). The\n`nowSeconds` argument exists so tests can pin a fixed clock; leave it unset in production.\n\n`parseWebhookEvent(raw)` parses the verified body and checks the envelope (`id`, `type`,\n`createdAt`, `data`). It returns the JSON as it arrived, typed as `DominaiteWebhookEvent`, so\nnarrowing on `event.type` gives you `PaymentWebhookData`, `AgreementWebhookData` or\n`ChargeWebhookData`. Nothing is renamed or defaulted: a field the gateway adds later comes through,\nand one an older gateway does not send yet (`apiVersion`, `sequence`) is simply absent. It throws\n`SyntaxError` for a body that is not JSON and `TypeError` for one that is not an envelope.\n\nThe recipe is pinned by the same offline vector every Dominaite SDK ships, so a Node verifier and a\nPython one agree byte-for-byte. `npm test` reproduces it.\n\n### Respond fast, dedupe, expect duplicates\n\n- **Respond 2xx immediately.** Queue the work; never do the fulfillment inline. A slow handler\n  looks like a failed one and earns you retries.\n- **Delivery is at-least-once.** Dedupe on the top-level `id`, which is stable across retries of\n  the same delivery. Handling an event twice must be harmless.\n- **Retries**: up to your configured `RetryCount` (default 3, max 10, 0 disables), spaced 1m, 5m,\n  30m, 2h, 12h, for as long as the endpoint is active.\n- **Circuit breaker**: an endpoint that fails its initial attempt and every configured retry, over\n  and over, is auto-disabled. Any later successful delivery re-enables it. An endpoint you disable\n  by hand in the dashboard stays disabled.\n- Order is not guaranteed. For `payment.*`, use `createdAt` and `previousStatus` rather than\n  assuming arrival order. For `agreement.*` and `charge.*`, use `data.sequence` (next section).\n\n### Ordering agreement and charge events\n\nEvery `agreement.*` and `charge.*` event carries an integer `data.sequence`, counted per object:\n\n> Deliveries can arrive out of order. Keep the highest sequence you have processed per object and\n> discard any event whose sequence is not higher; when you need current state, read the object by\n> id. createdAt can repeat across events, so order by sequence, not createdAt. A sequence of 0 only\n> comes from events recorded before the counter existed; treat it as older than any positive number.\n\nThe object is:\n\n- `agreement.*`: the agreement, `data.id`.\n- `charge.*` for a platform charge (one with an `agreementId`): the agreement period,\n  `data.agreementId` plus `data.periodNumber`.\n- `charge.*` for a one-off charge: `data.chargeId`.\n\n```js\nfunction orderingKey(event) {\n  if (event.type.startsWith('agreement.')) return `agreement:${event.data.id}`\n  if (event.data.agreementId) return `period:${event.data.agreementId}:${event.data.periodNumber}`\n  return `charge:${event.data.chargeId}`\n}\n\n// Inside your worker, in the same database transaction as the work itself.\nconst key = orderingKey(event)\nconst seen = await highestSequence(key)          // undefined when you have none yet\nif (seen !== undefined && event.data.sequence <= seen) {\n  return                                          // stale or duplicate, drop it\n}\nawait applyEvent(event)\nawait saveHighestSequence(key, event.data.sequence)\n```\n\n`sequence` is typed optional because a gateway older than this contract does not send it. If you\nreceive events without it, you cannot order them by sequence; read the object by id instead.\n\n### Webhooks do not replace your reconciliation sweep\n\n**Keep the sweep.** A periodic job that lists your own open orders and calls `getStatus` on each is\nstill mandatory, and webhooks complement it rather than retiring it. There are real windows where a\ndelivery never lands: an endpoint sitting disabled parks its chain, and a delivery can be lost\nbefore it is ever queued. Nothing in the webhook pipeline is a durable outbox, so the sweep is your\nbackstop for the money you would otherwise never hear about.\n\nRun it on a schedule, over every order that is not in a terminal state, and treat what `getStatus`\nsays as the truth.\n\n### Fallback: polling and in-flight UX\n\nWebhooks tell you about terminal outcomes. For the \"we are still working on it\" screen the payer\nsees right after paying, and as the fallback when you have no endpoint registered yet, poll:\n\n```js\nconst status = await client.getStatus(session.transactionId)\n// { transactionId, orderReference: 'order-1042', status: 'succeeded',\n//   amount: 2500, currency: 'EUR', ... }\n```\n\n`status` is one of: `pending`, `processing`, `succeeded`, `failed`, `refunded`,\n`partially_refunded`, `cancelled`, `disputed`, `requires_capture`, `abandoned`. While the session\nis still payable the response also carries `expiresAt`; after that instant a `pending` session can\nonly become `abandoned`. An unknown transaction id throws an `ApiError` with `httpStatus` 404.\n\n`succeeded` is the only value that means the payment is complete. Keep polling on `pending`,\n`processing` and `requires_capture` - none of them is terminal.\n\n`requires_capture` is **not** \"unpaid\": the payer has already paid and the funds are held\nawaiting capture. Never treat it as an abandoned order.\n\nTreat any status you do not recognise as still-open as well: a value the API adds later should\nmake you keep polling, never silently close an order that is still live.\n\n`isPaid` and `isTerminal` encode those rules so your sweep does not have to:\n\n```js\nimport { isPaid, isTerminal } from '@dominaite/merchant-sdk'\n\nconst { status } = await client.getStatus(order.transactionId)\nif (isPaid(status)) {\n  await fulfil(order)            // succeeded, and only succeeded\n} else if (isTerminal(status)) {\n  await close(order, status)     // failed, cancelled, abandoned, refunded, partially_refunded\n}\n// Anything else (pending, processing, requires_capture, disputed, or a value this SDK\n// does not know yet) is still open: poll again later.\n```\n\nPoll after the payer returns to you, or on your order timeout - not in a tight loop; the endpoint\nis rate limited per key. The platform allows 60 requests a minute per API key and 120 a minute per\nIP; going over throws `RateLimitError`, which carries `retryAfterSeconds`.\n\n## Errors\n\nEverything thrown by the SDK extends `DominaiteError`.\n\n| Error | When | What to do |\n|---|---|---|\n| `CheckoutRefusedError` | The API answered, `success: false`. `errorCode` carries the reason. | Branch on `errorCode`. Do not blind-retry. |\n| `StorefrontError` | 409 or 400 about the website the payment belongs to. `errorCode` is a storefront code, see below. Extends `ApiError`. | Fix the storefront setup. Retrying does not help. |\n| `RefundError` | A refund route refused with one of the refund codes. Extends `ApiError`; `retryable` says whether the same request can succeed later. | See [Refunds](#refunds). |\n| `AuthenticationError` | 401/403. `errorCode` is `INVALID_API_KEY`, `INVALID_SIGNATURE`, `TIMESTAMP_OUT_OF_RANGE`, or `IP_NOT_ALLOWED`. | Fix the key id, secret, server clock, or allowlist. Never retry-loop. |\n| `RateLimitError` | 429. You went over 60 requests/min for the key or 120/min for the IP. `retryAfterSeconds` carries `Retry-After` when it was a whole number of seconds, else `null`. | Wait `retryAfterSeconds` (or your own backoff), then send it again with the **same** idempotency key. The SDK does not retry this for you. |\n| `TransportError` | Network failure, timeout, 5xx (`MERCHANT_API_UNAVAILABLE`), or a response body over 10MB. | Retry with the **same** idempotency key, and expect a replay refusal if the first attempt did land. |\n| `ApiError` | Any other rejecting or unexpected response; `httpStatus` carries the code. | Inspect. A 422 means an idempotency key was replayed with a different body - use a fresh key. |\n| `ApiError` with a 3xx `httpStatus` | The host you called answered with a redirect. | The Dominaite API never redirects, so the SDK refuses to follow one: your signed headers would be handed to whatever `Location` names, and its answer would look authentic. Check `baseUrl` and any proxy in front of it. |\n| `TypeError` | Bad arguments (float amount, missing field or idempotency key, malformed key id). | Fix the call; nothing was sent. |\n\nRefusal codes on `CheckoutRefusedError.errorCode`:\n\n- `PAYMENT_PROCESSING_UNAVAILABLE` - card payments are off right now; retry later with the same\n  key. `createCheckoutSessionWithRetry` does this for you.\n- `DUPLICATE_REQUEST` - the session for this key is still open but cannot be handed back right\n  now (still being created, or expired and not settled yet); re-POST the same key shortly, never a\n  fresh one.\n- `ALREADY_PROCESSED` - this idempotency key's payment already completed.\n- `PRIOR_ATTEMPT_FAILED` - a prior attempt with this key failed terminally; use a fresh key.\n- `IDEMPOTENCY_KEY_REUSED` - same key sent with a different body; use a fresh key.\n\nEvery code above has a named constant on `ErrorCodes`, so a typo fails to compile instead of\nsilently never matching:\n\n```js\nimport { CheckoutRefusedError, ErrorCodes } from '@dominaite/merchant-sdk'\n\nif (error instanceof CheckoutRefusedError && error.errorCode === ErrorCodes.ALREADY_PROCESSED) {\n  // ...\n}\n```\n\n### Storefront errors\n\nIf you run more than one website under one merchant account, every session and charge is\nattributed to a storefront (one website). When the gateway cannot use that storefront it refuses\nthe request before anything is created, and the SDK throws `StorefrontError`:\n\n| `errorCode` | HTTP | Meaning | What to do |\n|---|---|---|---|\n| `STOREFRONT_NOT_WHITELISTED` | 409 | The site's domain is not whitelisted at the payment provider yet. Usually a new website. | Ask Dominaite support to finish the whitelisting. |\n| `STOREFRONT_INACTIVE` | 409 | The storefront was deactivated or deleted. | Use the key for an active site, or ask Dominaite support to reactivate it. |\n| `STOREFRONT_MISMATCH` | 400 | The storefront in the request is not the one your API key is bound to. | Use the API key issued for that website. |\n\n```js\nimport { ErrorCodes, StorefrontError } from '@dominaite/merchant-sdk'\n\ntry {\n  session = await client.createCheckoutSession(params)\n} catch (error) {\n  if (error instanceof StorefrontError && error.errorCode === ErrorCodes.STOREFRONT_NOT_WHITELISTED) {\n    // Configuration, not a blip: alert yourself and show the payer a \"try later\" page.\n  }\n}\n```\n\n`StorefrontError` extends `ApiError` (with `httpStatus` and `errorCode`), so an existing `ApiError`\nbranch still catches it. These are never retried, by `createCheckoutSessionWithRetry` or by you.\n\n### Recovering from a replay refusal\n\nWhen your idempotency key collides with an earlier attempt, the refusal names the transaction it\ncollided with, so you can reconcile instead of minting a second payment:\n\n```js\ntry {\n  session = await client.createCheckoutSession(params)\n} catch (error) {\n  if (error instanceof CheckoutRefusedError && error.transactionId) {\n    const status = await client.getStatus(error.transactionId)\n    // Now you know what the earlier attempt actually did.\n  }\n}\n```\n\n`error.transactionId` is `undefined` when the API did not name one (a concurrent-race\n`DUPLICATE_REQUEST` knows the key is taken but not yet by which row), so check it before use. The\nfull refusal payload is on `error.result`.\n\nA refusal carries the status of the earlier payment, not its session: no refusal has `cashierKey`\nor `cashierToken`. That is fine, because the one case where the session is still payable (open and\nunexpired) is not a refusal: the replay returns the original session itself. Reconcile refusals\nagainst the status.\n\nOne replay is not a refusal at all. A session that expired unpaid is superseded: from a few\nminutes past `expiresAt`, re-POSTing the same key returns an ordinary success with a fresh session\n(new `transactionId`, same key), so a customer who comes back late just pays. Keep the\norder-derived key for the life of the order to keep that path open. The band is not endless - once\nthe platform has independently closed the attempt (about an hour past expiry), the replay answers\n`PRIOR_ATTEMPT_FAILED` and the key is spent; reconcile and use a fresh key.\n\n## Verifying your signing\n\nThe SDK signs for you, but the recipe is pinned by an offline known-answer vector shared with the\ngateway and the dashboard - `npm test` reproduces it byte-for-byte. If you ever hand-roll the\nsigning (or debug an `INVALID_SIGNATURE`), `signRequest` is exported:\n\n```js\nimport { signRequest } from '@dominaite/merchant-sdk'\n\nsignRequest({\n  secret: 'dms_...',\n  timestamp: '1755302400',                                  // unix SECONDS\n  method: 'POST',\n  path: '/merchant-api/checkout/sessions',                   // path only, no host\n  idempotencyKey: '00000000-0000-4000-8000-000000000001',    // '' for GET\n  body: '{\"amount\":2500,\"currency\":\"EUR\",\"orderReference\":\"order-1042\"}',  // '' for GET\n})\n// '8f5fba0b29a8eea81b76a0e6d7119e79ec68f586910f77713b045652e5ce9b74'\n```\n\nThe signed payload is five lines:\n`\"{timestamp}\\n{METHOD}\\n{path}\\n{idempotencyKey}\\n{sha256hex(body)}\"`, signed as lowercase hex\nHMAC-SHA256 with your secret, UTF-8 throughout. GET signs an empty idempotency key and an empty\nbody, and sends no `Idempotency-Key` header.\n","readmeFilename":"README.md"}