{"_id":"@aliziodev/singapay","_rev":"5-5fa1f052dcced5e4a54934cf6a619458","name":"@aliziodev/singapay","dist-tags":{"latest":"1.1.2"},"versions":{"1.0.0":{"name":"@aliziodev/singapay","version":"1.0.0","keywords":["singapay","payment","payment-gateway","indonesia","qris","virtual-account","e-wallet","disbursement","webhook"],"author":{"name":"Alizio","email":"aliziodev@gmail.com"},"license":"MIT","_id":"@aliziodev/singapay@1.0.0","maintainers":[{"name":"aliziodev","email":"aliziodev@gmail.com"}],"homepage":"https://github.com/aliziodev/singapay-js","bugs":{"url":"https://github.com/aliziodev/singapay-js/issues"},"dist":{"shasum":"203d9c2db243d2d424933370d9c825755dbc0f46","tarball":"https://registry.npmjs.org/@aliziodev/singapay/-/singapay-1.0.0.tgz","fileCount":18,"integrity":"sha512-eY0uFC2J07VfyKmV8Tu7Rc+P0Y+pW6cTksFMi7T5NxgLuxDDz+S80stgArVG3LBji7P86Qybq6R5FKZjBX9HNQ==","signatures":[{"sig":"MEUCIEshJLfvaKOj/uQ6IluIccvjCgXygUlcXjJzIT6FS/1+AiEA7ECiEXCGY9qShiK/CRJs52rwNDKCZuUTiWUO+ii2IHc=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aliziodev%2fsingapay@1.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":433942},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.cts","module":"./dist/index.mjs","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"browser":"./dist/browser-guard.mjs","require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"f2e7e68796cad5890f95e66707b66f2657bf7a98","scripts":{"lint":"biome check .","test":"vitest run","build":"tsdown --config-loader native","lint:fix":"biome check --write .","test:e2e":"vitest run --config vitest.e2e.config.ts","typecheck":"tsc -b","test:watch":"vitest"},"_npmUser":{"name":"aliziodev","email":"aliziodev@gmail.com"},"repository":{"url":"git+https://github.com/aliziodev/singapay-js.git","type":"git"},"_npmVersion":"12.0.2","description":"Unofficial SingaPay payment gateway SDK — framework-agnostic, server-only, zero runtime dependencies.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.24.0","devDependencies":{"tsdown":"^0.22.14","vitest":"^4.1.11","typescript":"^7.0.2","@types/node":"^26.2.0","@biomejs/biome":"^2.5.10"},"_npmOperationalInternal":{"tmp":"tmp/singapay_1.0.0_1787436981536_0.7840982900678402","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@aliziodev/singapay","version":"1.0.1","keywords":["singapay","payment","payment-gateway","indonesia","qris","virtual-account","e-wallet","disbursement","webhook"],"author":{"name":"Alizio","email":"aliziodev@gmail.com"},"license":"MIT","_id":"@aliziodev/singapay@1.0.1","maintainers":[{"name":"aliziodev","email":"aliziodev@gmail.com"}],"homepage":"https://github.com/aliziodev/singapay-js","bugs":{"url":"https://github.com/aliziodev/singapay-js/issues"},"dist":{"shasum":"9f87111c83eba2de42399370b5c6a2de292b42d7","tarball":"https://registry.npmjs.org/@aliziodev/singapay/-/singapay-1.0.1.tgz","fileCount":18,"integrity":"sha512-VhHjGU35l9qAPd/Fa4NCrfMusCyDBlTA7/tocv6unnMtg13RNlGgu7rCPUtBYUT0lb1FWse6Lj0qeAWLIxLI0w==","signatures":[{"sig":"MEYCIQC7tjk1Quo1bWwAeuLr93llq1rUZhOdlvkUlCninPSmogIhAOe91ZR08wlY4C4EJl7geRp2EKkq2D89TW6uAPBiVBeu","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aliziodev%2fsingapay@1.0.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":433953},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.cts","module":"./dist/index.mjs","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"browser":"./dist/browser-guard.mjs","require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"8968066e5ddd44609d5713b1b1b7675dadd86d3f","scripts":{"lint":"biome check .","test":"vitest run","build":"tsdown --config-loader native","lint:fix":"biome check --write .","test:e2e":"vitest run --config vitest.e2e.config.ts","typecheck":"tsc -b","test:watch":"vitest"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:282267a8-125f-46ea-99c0-a219e12b5540"}},"repository":{"url":"git+https://github.com/aliziodev/singapay-js.git","type":"git"},"_npmVersion":"12.0.2","description":"Unofficial SingaPay payment gateway SDK — framework-agnostic, server-only, zero runtime dependencies.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.24.0","devDependencies":{"tsdown":"^0.22.14","vitest":"^4.1.11","typescript":"^7.0.2","@types/node":"^26.2.0","@biomejs/biome":"^2.5.10"},"_npmOperationalInternal":{"tmp":"tmp/singapay_1.0.1_1787438156189_0.4843708244029665","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@aliziodev/singapay","version":"1.1.0","keywords":["singapay","payment","payment-gateway","indonesia","qris","virtual-account","e-wallet","disbursement","webhook"],"author":{"name":"Alizio","email":"aliziodev@gmail.com"},"license":"MIT","_id":"@aliziodev/singapay@1.1.0","maintainers":[{"name":"aliziodev","email":"aliziodev@gmail.com"}],"homepage":"https://github.com/aliziodev/singapay-js","bugs":{"url":"https://github.com/aliziodev/singapay-js/issues"},"dist":{"shasum":"29851afb96d5d3b82dadbf71a8abe78bbd51f042","tarball":"https://registry.npmjs.org/@aliziodev/singapay/-/singapay-1.1.0.tgz","fileCount":19,"integrity":"sha512-QdQEHO5VPdOPnSYGS2VBN2LUueU9z1y5V/wn3sT1LFloMsHbXQQXVcLTjDqaFGgiPA56jF0rjfrEXNLPS4TuWA==","signatures":[{"sig":"MEYCIQDhmiPtIFhiXfdqLIBAvT0weJ8ly0kFPxjY1Ck/zQ1cGQIhALnVGq2WkU1VcbHJ/mZJNT7kT0O+oJCD6bb/HDl4zr19","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aliziodev%2fsingapay@1.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":439860},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.cts","module":"./dist/index.mjs","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"browser":"./dist/browser-guard.mjs","require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"6c788aa37a23a3e161cc08c198619ee3d613fab2","scripts":{"lint":"biome check .","test":"vitest run","build":"tsdown --config-loader native","lint:fix":"biome check --write .","test:e2e":"vitest run --config vitest.e2e.config.ts","typecheck":"tsc -b","test:watch":"vitest"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:282267a8-125f-46ea-99c0-a219e12b5540"}},"repository":{"url":"git+https://github.com/aliziodev/singapay-js.git","type":"git"},"_npmVersion":"12.0.2","description":"Unofficial SingaPay payment gateway SDK — framework-agnostic, server-only, zero runtime dependencies.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.24.0","devDependencies":{"tsdown":"^0.22.14","vitest":"^4.1.11","typescript":"^7.0.2","@types/node":"^26.2.0","@biomejs/biome":"^2.5.10"},"_npmOperationalInternal":{"tmp":"tmp/singapay_1.1.0_1787474827898_0.8988971579753235","host":"s3://npm-registry-packages-npm-production"}},"1.1.1":{"name":"@aliziodev/singapay","version":"1.1.1","keywords":["singapay","payment","payment-gateway","indonesia","qris","virtual-account","e-wallet","disbursement","webhook"],"author":{"name":"Alizio","email":"aliziodev@gmail.com"},"license":"MIT","_id":"@aliziodev/singapay@1.1.1","maintainers":[{"name":"aliziodev","email":"aliziodev@gmail.com"}],"homepage":"https://github.com/aliziodev/singapay-js","bugs":{"url":"https://github.com/aliziodev/singapay-js/issues"},"dist":{"shasum":"d266ed3cafedd7bff792f66032643187a6c702a4","tarball":"https://registry.npmjs.org/@aliziodev/singapay/-/singapay-1.1.1.tgz","fileCount":19,"integrity":"sha512-nkU7Z5xn2uxLwGwpqCoxus92AcQ0E0+YBfaqPkmbfdkPj86WmzHf12Wo9DO1K14fpGVk2v7UelOvJpJ6olb+eA==","signatures":[{"sig":"MEYCIQCwLCoRMPwM+2rvV/oUj0TPRCcEcpZZti8m+W8yEXB9qAIhAL9FWcre7SDrenh3nyDXwQxoNhhfF5n0l5/lt1LA20wS","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aliziodev%2fsingapay@1.1.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":440158},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.cts","module":"./dist/index.mjs","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"browser":"./dist/browser-guard.mjs","require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"gitHead":"653bfa3b86c26ba6fbdd4e3e8b3021f82c2cbcfe","scripts":{"lint":"biome check .","test":"vitest run","build":"tsdown --config-loader native","lint:fix":"biome check --write .","test:e2e":"vitest run --config vitest.e2e.config.ts","typecheck":"tsc -b","test:watch":"vitest"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:282267a8-125f-46ea-99c0-a219e12b5540"}},"repository":{"url":"git+https://github.com/aliziodev/singapay-js.git","type":"git"},"_npmVersion":"12.0.2","description":"Unofficial SingaPay payment gateway SDK — framework-agnostic, server-only, zero runtime dependencies.","directories":{},"sideEffects":false,"_nodeVersion":"22.23.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.24.0","devDependencies":{"tsdown":"^0.22.14","vitest":"^4.1.11","typescript":"^7.0.2","@types/node":"^26.2.0","@biomejs/biome":"^2.5.10"},"_npmOperationalInternal":{"tmp":"tmp/singapay_1.1.1_1787475874524_0.6937139209447742","host":"s3://npm-registry-packages-npm-production"}},"1.1.2":{"name":"@aliziodev/singapay","version":"1.1.2","description":"Unofficial SingaPay payment gateway SDK — framework-agnostic, server-only, zero runtime dependencies.","keywords":["singapay","payment","payment-gateway","indonesia","qris","virtual-account","e-wallet","disbursement","webhook"],"license":"MIT","author":{"name":"Alizio","email":"aliziodev@gmail.com"},"homepage":"https://github.com/aliziodev/singapay-js","repository":{"type":"git","url":"git+https://github.com/aliziodev/singapay-js.git"},"type":"module","packageManager":"pnpm@10.24.0","engines":{"node":">=20"},"sideEffects":["./dist/browser-guard.mjs","./dist/browser-guard.cjs"],"main":"./dist/index.cjs","module":"./dist/index.mjs","types":"./dist/index.d.cts","exports":{".":{"browser":"./dist/browser-guard.mjs","import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"scripts":{"build":"tsdown --config-loader native","test":"vitest run","test:watch":"vitest","test:e2e":"vitest run --config vitest.e2e.config.ts","typecheck":"tsc -b","lint":"biome check .","lint:fix":"biome check --write ."},"devDependencies":{"@biomejs/biome":"^2.5.10","@types/node":"^26.2.0","tsdown":"^0.22.14","typescript":"^7.0.2","vitest":"^4.1.11"},"publishConfig":{"access":"public"},"gitHead":"65c7b9380510f59bb79989fb6cb249ecfd9f9194","_id":"@aliziodev/singapay@1.1.2","bugs":{"url":"https://github.com/aliziodev/singapay-js/issues"},"_nodeVersion":"22.23.2","_npmVersion":"12.0.2","dist":{"integrity":"sha512-wudYyAyLXq5XVt6kdpQXjCE8/RUwGNu0z7W7szF6leMMhRIvo+ispViPROIHEDvLQrL5NzehjK8Nl6wwXPNjuA==","shasum":"ac03c3db4df8ba5f87b9faf3a572c8e4c44ab68c","tarball":"https://registry.npmjs.org/@aliziodev/singapay/-/singapay-1.1.2.tgz","fileCount":19,"unpackedSize":444267,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@aliziodev%2fsingapay@1.1.2","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIAcc8JrtyvJYp1hrr06zatIa5PtKtBcQDJ8xeih57P6NAiEA/ubhECSzpn6T3piSS5NXPrngU/hjUBRY6gIxkmuuhHY="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:282267a8-125f-46ea-99c0-a219e12b5540"}},"directories":{},"maintainers":[{"name":"aliziodev","email":"aliziodev@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/singapay_1.1.2_1787488780823_0.5190253115172294"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-22T22:16:21.370Z","modified":"2026-08-23T12:39:41.268Z","1.0.0":"2026-08-22T22:16:21.678Z","1.0.1":"2026-08-22T22:35:56.340Z","1.1.0":"2026-08-23T08:47:08.036Z","1.1.1":"2026-08-23T09:04:34.674Z","1.1.2":"2026-08-23T12:39:40.966Z"},"bugs":{"url":"https://github.com/aliziodev/singapay-js/issues"},"author":{"name":"Alizio","email":"aliziodev@gmail.com"},"license":"MIT","homepage":"https://github.com/aliziodev/singapay-js","keywords":["singapay","payment","payment-gateway","indonesia","qris","virtual-account","e-wallet","disbursement","webhook"],"repository":{"type":"git","url":"git+https://github.com/aliziodev/singapay-js.git"},"description":"Unofficial SingaPay payment gateway SDK — framework-agnostic, server-only, zero runtime dependencies.","maintainers":[{"name":"aliziodev","email":"aliziodev@gmail.com"}],"readme":"# SingaPay JS\n\n[![Tests](https://github.com/aliziodev/singapay-js/actions/workflows/tests.yml/badge.svg)](https://github.com/aliziodev/singapay-js/actions/workflows/tests.yml)\n[![npm](https://img.shields.io/npm/v/@aliziodev/singapay)](https://www.npmjs.com/package/@aliziodev/singapay)\n[![downloads](https://img.shields.io/npm/dm/@aliziodev/singapay)](https://www.npmjs.com/package/@aliziodev/singapay)\n[![license](https://img.shields.io/github/license/aliziodev/singapay-js)](LICENSE)\n\nSDK **tidak resmi** untuk payment gateway [SingaPay](https://singapay.id) (PT Abadi Singapay Indonesia, PJP1 berizin Bank Indonesia). Repo ini tidak berafiliasi dengan PT Abadi Singapay Indonesia.\n\nSingaPay tidak menyediakan SDK resmi untuk bahasa apa pun. Setiap integrator harus mengimplementasikan sendiri beberapa skema tanda tangan HMAC yang berbeda, normalisasi JSON yang presisi byte-per-byte, manajemen token, dan verifikasi webhook. Paket ini mengerjakan semuanya.\n\nSatu paket, framework-agnostic, tanpa dependency runtime. Berjalan di Node 20+, Bun, Deno, dan edge runtime — seluruh kriptografi memakai Web Crypto API (`crypto.subtle`).\n\n> 🤖 **Memakai AI coding assistant?** Arahkan ke **[`llms.txt`](llms.txt)** — ringkasan padat berisi hal-hal yang tidak bisa ditebak dan **gagal secara diam-diam** kalau salah: `apiKey` bukan `partnerId`, baris daftar ada di `items` bukan `data`, dua kosakata field yang berbeda antara transfer dan pengecekannya, dan verifier webhook yang butuh secret **semua** kredensial. Ikut terpasang bersama paket, jadi bisa dibaca dari `node_modules/@aliziodev/singapay/llms.txt`.\n>\n> Untuk agen yang mengerjakan repo ini sendiri: [`AGENTS.md`](AGENTS.md).\n\n- [Instalasi](#instalasi)\n- [Pakai](#pakai)\n- [⚠️ Baca dulu sebelum produksi](#baca-dulu-sebelum-produksi)\n- [Konfigurasi](#konfigurasi)\n- [Endpoint](#endpoint)\n- [Money-out](#money-out)\n- [Webhook](#webhook)\n- [Deploy di platform serverless](#deploy-di-platform-serverless)\n- [Nominal](#nominal)\n- [Error](#error)\n- [Kenapa server-only](#kenapa-server-only)\n- [Cakupan](#cakupan)\n- [Kanonikalisasi & signature vectors](#kanonikalisasi--signature-vectors)\n- [Pengembangan](#pengembangan)\n- [Lisensi](#lisensi)\n\n## Instalasi\n\nPilih salah satu sesuai package manager Anda.\n\n**npm**\n\n```bash\nnpm install @aliziodev/singapay\n```\n\n**pnpm**\n\n```bash\npnpm add @aliziodev/singapay\n```\n\n**yarn**\n\n```bash\nyarn add @aliziodev/singapay\n```\n\n**bun**\n\n```bash\nbun add @aliziodev/singapay\n```\n\n**Deno**\n\n```bash\ndeno add npm:@aliziodev/singapay\n```\n\nTanpa dependency runtime, jadi tidak ada apa pun yang ikut terpasang.\n\nTidak ada paket terpisah untuk Next.js, Nuxt, Express, atau lainnya. Semuanya dilayani paket ini — lihat [resep webhook per framework](#resep-per-framework).\n\n## Pakai\n\n```ts\nimport { SingaPay } from '@aliziodev/singapay';\n\nconst singapay = new SingaPay({\n  environment: 'sandbox',\n  clientId: process.env.SINGAPAY_CLIENT_ID!,\n  clientSecret: process.env.SINGAPAY_CLIENT_SECRET!,\n  apiKey: process.env.SINGAPAY_API_KEY!,\n  accountId: process.env.SINGAPAY_ACCOUNT_ID!,\n});\n\nconst link = await singapay.paymentLinks.create({\n  reff_no: 'INV-1001',\n  payment_link_type: 'total',\n  total_amount: 150_000,\n});\n\nconsole.log(link.data.payment_url);\n```\n\n## ⚠️ Baca dulu sebelum produksi\n\n1. **IP whitelist wajib.** SingaPay menolak request dari IP yang tidak terdaftar (`SP017`). Vercel, Netlify, dan Cloudflare Workers memakai IP egress dinamis dan **tidak bisa** di-whitelist — ini kendala arsitektur, bukan masalah konfigurasi. Lihat [Deploy di platform serverless](#deploy-di-platform-serverless).\n2. **Server-only secara desain.** Setiap request ditandatangani dengan `client_secret`. Paket ini menolak dimuat di browser — lihat [Kenapa server-only](#kenapa-server-only).\n3. **Money-out mati secara default.** Semua operasi yang memindahkan uang melempar `MoneyOutDisabledError` sampai Anda menyetel `moneyOut: { enabled: true }`.\n4. **Jangan pernah retry money-out secara buta.** Setelah `SP001`/`SP005`/timeout, panggil `inquireStatus()` dengan reference yang sama sebelum melakukan apa pun. Retry buta bisa menduplikasi transfer uang sungguhan. SDK ini hanya me-retry otomatis untuk GET.\n5. **Endpoint Card = ruang lingkup PCI-DSS.** Gunakan Payment Link kecuali Anda benar-benar paham konsekuensinya.\n6. **Jadwal settlement & rolling reserve tidak terdokumentasi** oleh SingaPay — tanyakan langsung sebelum go-live.\n\n## Konfigurasi\n\n```ts\nexport const singapay = new SingaPay({\n  environment: 'sandbox',            // default 'sandbox'\n  clientId: process.env.SINGAPAY_CLIENT_ID!,\n  clientSecret: process.env.SINGAPAY_CLIENT_SECRET!,\n  apiKey: process.env.SINGAPAY_API_KEY!,\n  accountId: process.env.SINGAPAY_ACCOUNT_ID!,   // ULID akun default\n  authVersion: '1.1',                // '1.1' (HMAC) atau '1.0' (Basic)\n  timeoutMs: 30_000,\n  retry: { times: 2, delayMs: 200 }, // hanya berlaku untuk GET\n  moneyOut: { enabled: false },      // wajib true untuk operasi yang memindahkan uang\n  webhooks: {\n    toleranceSeconds: 300,\n    secrets: [process.env.SINGAPAY_HMAC_KEY!], // HMAC Validation Key dari dashboard\n  },\n});\n```\n\nAda juga `optionsFromEnv()` yang membaca variabel `SINGAPAY_*`:\n\n```ts\nimport { optionsFromEnv, SingaPay } from '@aliziodev/singapay';\n\nconst singapay = new SingaPay(optionsFromEnv({ moneyOut: { enabled: true } }));\n```\n\n### Dari dashboard ke konfigurasi\n\nHalaman **Credential Details** di dashboard SingaPay memakai nama yang berbeda dari header protokolnya. Paket ini mengikuti nama di dashboard, karena di situlah Anda menyalinnya:\n\n| Field di dashboard | Opsi di paket ini | Variabel env | Dipakai untuk |\n|---|---|---|---|\n| Client ID | `clientId` | `SINGAPAY_CLIENT_ID` | Identitas klien di semua skema auth |\n| Client Secret | `clientSecret` | `SINGAPAY_CLIENT_SECRET` | Kunci HMAC tanda tangan **keluar**. Tidak pernah dikirim |\n| **API Key** | **`apiKey`** | `SINGAPAY_API_KEY` | Dikirim sebagai header **`X-PARTNER-ID`** di setiap request |\n| HMAC Validation Key | `webhooks.secrets` | `SINGAPAY_HMAC_KEY` | Verifikasi tanda tangan webhook **masuk**. Satu per credential — lihat di bawah |\n\n> **API Key dan Client Secret paling sering tertukar.** API Key adalah *identitas* yang dikirim di header; Client Secret adalah *kunci tanda tangan* yang tidak pernah meninggalkan server Anda. Tertukar berarti token exchange gagal dengan error autentikasi yang tidak menyebut sebabnya.\n\nKalau Anda menelusuri header request atau membaca dokumentasi API SingaPay, `apiKey` inilah yang muncul sebagai `X-PARTNER-ID`. `SINGAPAY_PARTNER_ID` tetap diterima sebagai nama variabel env, jadi konfigurasi lama tidak perlu diubah.\n\n### Multiple credentials\n\nSatu merchant bisa memegang beberapa credential dashboard: satu **Default** milik merchant, plus **Specific** yang terikat ke sub-akun tertentu. Ini bukan pilihan desain — `SP403` menolak credential Default untuk akun yang sudah punya credential sendiri.\n\nCredential top-level adalah connection bernama `default`. Sisanya didaftarkan di `connections`:\n\n```ts\nconst singapay = new SingaPay({\n  environment: 'production',\n  clientId: process.env.SINGAPAY_CLIENT_ID!,\n  clientSecret: process.env.SINGAPAY_CLIENT_SECRET!,\n  apiKey: process.env.SINGAPAY_API_KEY!,\n  moneyOut: { enabled: true },\n  connections: {\n    payouts: {\n      clientId: process.env.SINGAPAY_PAYOUTS_CLIENT_ID!,\n      clientSecret: process.env.SINGAPAY_PAYOUTS_CLIENT_SECRET!,\n      apiKey: process.env.SINGAPAY_PAYOUTS_API_KEY!,\n      accountId: '01J0PAYOUTSACCOUNT',\n    },\n  },\n});\n\nawait singapay.paymentLinks.create({ ... });                        // default\nawait singapay.connection('payouts').disbursement.transfer({ ... });\n```\n\n**Hanya credential yang per-connection** — `clientId`, `clientSecret`, `apiKey`, `accountId`, `authVersion`. Environment, base URL, guard money-out, timeout, retry, toleransi webhook, dan logger adalah kebijakan aplikasi dan tetap dibagi bersama.\n\nInstance connection di-memo dan murah. Semuanya berbagi `tokenStore` yang sama, dan token di-cache per client id, jadi satu connection tidak pernah memakai token milik yang lain.\n\n`singapay.connectionNames` memuat semua nama yang terkonfigurasi, `default` di urutan pertama. `optionsFromEnv()` hanya mengisi connection `default` — daftarkan sisanya di kode.\n\n### Token store\n\nToken di-cache di memori proses secara default, dan itu tidak berguna di serverless — tiap invocation bisa mendapat instance baru dan mengambil token lagi. Sediakan store Anda sendiri:\n\n```ts\nconst singapay = new SingaPay({\n  ...credentials,\n  tokenStore: {\n    get: (key) => redis.get(key),\n    set: (key, token, ttl) => redis.set(key, token, 'EX', ttl),\n    delete: (key) => redis.del(key),\n  },\n});\n```\n\n## Endpoint\n\nSetiap grup mengembalikan `SingaPayResponse` yang seragam: `{ status, code, message, data, items, raw, successful }`, apa pun generasi envelope yang dipakai gateway.\n\n`data` selalu objek — dipakai untuk pembacaan satu record seperti `response.data.payment_url`. Endpoint yang mengembalikan daftar (`list()`, `paymentMethods()`, dan sejenisnya) menaruh barisnya di **`items`**, bukan di `data`:\n\n```ts\nconst accounts = await singapay.accounts.list();\n\nfor (const row of accounts.items ?? []) {\n  // ...\n}\n```\n\n`items` bernilai `null` untuk respons satu record, dan array kosong untuk daftar yang memang kosong — jadi keduanya bisa dibedakan. `raw` selalu memuat body apa adanya.\n\n**Money in**\n\n| Properti | Isi |\n|---|---|\n| `paymentLinks` | `list` `paymentMethods` `create` `find` `update` `delete` |\n| `paymentLinkHistories` | `list` `find` |\n| `virtualAccounts` | `list` `create` `find` `update` `delete` |\n| `vaTransactions` | `list` `find` `listByVaNumber` |\n| `qris` | `generate` `list` `find` |\n| `ewallet` | `createCheckout` `createOrder` `listTransactions` `findTransaction` `inquireStatus` |\n| `card` | `payment` `cancel` `inquireStatus` |\n| `directDebit` | `bindCard` `bindingStatus` `unbindCard` `charge` `verifyOtp` `findTransaction` |\n| `subscriptions` | `createPlan` `findPlan` `updatePlan` `cancelPlan` |\n\n**Money out** — semuanya di belakang guard `moneyOut`\n\n| Properti | Isi |\n|---|---|\n| `disbursement` | `list` `find` `checkFee` `checkBeneficiary` `transfer` `inquireStatus` |\n| `ewalletMoneyOut` | `inquireAccount` `triggerTopup` `inquireStatus` |\n| `qrisMoneyOut` | `inquireMerchant` `triggerPaymentCredit` `inquireStatus` |\n| `accountTransfer` | `list` `find` `transfer` |\n| `cardlessWithdrawal` | `create` `list` `find` `cancel` |\n\n**Akun**\n\n| Properti | Isi |\n|---|---|\n| `accounts` | `list` `create` `find` `update` `updateStatus` `delete` |\n| `balance` | `merchant` `account` |\n| `statements` | `list` `find` |\n\nEndpoint yang belum dibungkus bisa dipanggil lewat `singapay.request()`, dengan auth, tanda tangan, retry, dan penanganan envelope yang sama.\n\n## Money-out\n\nSetiap operasi yang memindahkan uang melempar `MoneyOutDisabledError` sampai guard-nya dinyalakan:\n\n```ts\nconst singapay = new SingaPay({ ...credentials, moneyOut: { enabled: true } });\n\ntry {\n  await singapay.disbursement.transfer({\n    reference_number: 'PO-2026-0001',\n    amount: 1_500_000,\n    bank_account_number: '1234567890',\n    bank_code: '014', // tiga digit atau SWIFT\n  });\n} catch (error) {\n  if (error instanceof IndeterminateOutcomeError) {\n    // SP001 / SP005 — hasilnya BELUM tentu gagal. Jangan retry.\n    const status = await singapay.disbursement.inquireStatus('PO-2026-0001');\n  }\n}\n```\n\nPerhatikan kosakata field-nya berbeda antara transfer dan pengecekan, dan gateway menolak kalau tertukar:\n\n| Panggilan | Field bank | Field rekening |\n|---|---|---|\n| `transfer()` | `bank_code` — tiga digit atau SWIFT | `bank_account_number` |\n| `checkFee()` `checkBeneficiary()` | `bank_swift_code` — hanya SWIFT | `bank_account_number` |\n\nMengirim `bank_swift_code` ke `transfer()` ditolak `SP018`, dan `bank_code` ke pengecekan ditolak `422`.\n\n**Jangan percaya tabel bank yang dipublikasikan SingaPay.** Tabelnya memuat 100+ bank, tapi API menerima lebih sedikit — dari 20 yang disampel, 6 ditolak `422`, termasuk setiap entri syariah yang berbagi `bank_code` dengan induk konvensionalnya. Tidak ada endpoint yang memberi daftar sebenarnya, jadi validasi tujuan dengan `checkBeneficiary()` sebelum menjanjikan apa pun ke pelanggan.\n\n## Webhook\n\nVerifikasi butuh tiga hal: body sebagai string, header request, dan path callback persis seperti yang terdaftar di dashboard — termasuk query string, karena ikut ditandatangani.\n\n```ts\nconst verified = await singapay.verifyWebhook(rawBody, request.headers, '/api/webhooks/singapay');\n\nif (verified.type === 'va-transaction') {\n  await markPaid(verified.payload);\n}\n```\n\nYang diperiksa: perbandingan constant-time, toleransi timestamp untuk mencegah replay, dan beberapa kandidat secret sekaligus (client secret plus HMAC Validation Key).\n\n**Setiap credential punya HMAC Validation Key sendiri, dan Anda butuh semuanya.** Dashboard menampilkan satu di tab Default dan satu lagi di tiap tab Specific. Daftarkan semua:\n\n```ts\nwebhooks: { secrets: [hmacKeySpecific, hmacKeyDefault] }\n```\n\nLewat environment, `SINGAPAY_HMAC_KEY` menerima daftar yang dipisah koma — kunci hex tidak pernah mengandung koma, jadi pemisahannya tidak ambigu:\n\n```\nSINGAPAY_HMAC_KEY=kunci-specific,kunci-default\n```\n\nKalau Anda memakai [beberapa credential](#multiple-credentials), verifikasi otomatis mencoba client secret **semua** connection, dan connection mana yang dipakai untuk memanggil tidak berpengaruh. Ini bukan kemewahan: satu callback URL menerima delivery yang ditandatangani credential yang berbeda-beda. Diverifikasi di sandbox — disbursement yang dibuat dengan credential Specific ternyata dinotifikasi oleh credential **Default** merchant, dan ditandatangani dengan secret milik Default. Kalau secret itu tidak ikut dicoba, notifikasi money-out ditolak diam-diam di produksi.\n\nTanpa client, pakai primitifnya langsung:\n\n```ts\nimport { verifyWebhook } from '@aliziodev/singapay';\n\nconst verified = await verifyWebhook({\n  rawBody,\n  headers,\n  endpoint: '/api/webhooks/singapay',\n  secrets: [process.env.SINGAPAY_CLIENT_SECRET!],\n});\n```\n\n### Soal raw body\n\nHash dihitung atas **bentuk kanonik** payload lebih dulu, baru byte mentah. Artinya body yang sempat di-parse lalu di-serialisasi ulang **biasanya tetap lolos** — urutan key dan whitespace diserap normalisasi. Ini disengaja: delivery tidak ditolak hanya karena framework Anda sempat menyentuh body-nya.\n\nYang tidak bisa dipulihkan normalisasi adalah informasi yang hilang waktu parse — integer di atas `Number.MAX_SAFE_INTEGER` kembali sudah dibulatkan, dan jalur verifikasi byte-verbatim ikut hilang. Jadi tetap baca raw body kalau bisa; ongkosnya nol.\n\n`readWebhookBody()` mengerjakannya dengan satu panggilan di semua runtime:\n\n```ts\nimport { readWebhookBody } from '@aliziodev/singapay';\n\nconst rawBody = await readWebhookBody(source);\n```\n\n`source` boleh web `Request`, Node `IncomingMessage`, atau event h3 v1/v2 — dikenali secara struktural, tanpa peer dependency ke framework mana pun. Ia melempar `WebhookVerificationError` bila body sudah kosong, yang berarti ada body parser yang menghabiskan stream lebih dulu.\n\n### Resep per framework\n\n**Next.js App Router**\n\n```ts\n// app/api/webhooks/singapay/route.ts\nimport { readWebhookBody } from '@aliziodev/singapay';\nimport { singapay } from '@/lib/singapay';\n\nexport const runtime = 'nodejs';\nexport const dynamic = 'force-dynamic';\n\nexport async function POST(request: Request): Promise<Response> {\n  try {\n    const verified = await singapay.verifyWebhook(\n      await readWebhookBody(request),\n      request.headers,\n      '/api/webhooks/singapay',\n    );\n\n    if (verified.type === 'va-transaction') {\n      await markInvoicePaid(verified.payload);\n    }\n  } catch {\n    return Response.json({ error: 'Invalid signature' }, { status: 401 });\n  }\n\n  // Balas cepat. SingaPay mengirim ulang bila jawabannya bukan 2xx, jadi\n  // pekerjaan berat sebaiknya masuk queue, bukan dikerjakan di sini.\n  return Response.json({ received: true });\n}\n```\n\n**Nuxt / Nitro**\n\n```ts\n// server/api/webhooks/singapay.post.ts\nimport { readWebhookBody } from '@aliziodev/singapay';\nimport { singapay } from '~/server/utils/singapay';\n\nexport default defineEventHandler(async (event) => {\n  let verified;\n\n  try {\n    verified = await singapay.verifyWebhook(\n      await readWebhookBody(event),\n      getRequestHeaders(event),\n      '/api/webhooks/singapay',\n    );\n  } catch {\n    throw createError({ statusCode: 401, statusMessage: 'Invalid SingaPay signature' });\n  }\n\n  if (verified.type === 'va-transaction') {\n    await markInvoicePaid(verified.payload);\n  }\n\n  return { received: true };\n});\n```\n\nh3 punya `readRawBody(event)` sendiri yang auto-import di Nitro dan mengerjakan hal yang sama. Pakai mana pun yang lebih enak dibaca — `readWebhookBody()` ada supaya baris yang sama jalan di semua runtime.\n\n**Express**\n\n```ts\nimport express from 'express';\nimport { readWebhookBody } from '@aliziodev/singapay';\n\nconst app = express();\n\n// Jangan pasang express.json() sebelum route ini — ia menghabiskan stream.\napp.post('/api/webhooks/singapay', async (req, res) => {\n  try {\n    const verified = await singapay.verifyWebhook(\n      await readWebhookBody(req),\n      req.headers,\n      '/api/webhooks/singapay',\n    );\n\n    if (verified.type === 'va-transaction') {\n      await markInvoicePaid(verified.payload);\n    }\n  } catch {\n    return res.status(401).json({ error: 'Invalid signature' });\n  }\n\n  res.json({ received: true });\n});\n```\n\nHono, SvelteKit, Remix, Astro, Bun, dan Deno semuanya memberikan web `Request` — polanya sama persis dengan resep Next.js di atas.\n\n### Membaca payload delivery\n\nDua bentuk yang tidak bisa ditebak dan gagal secara diam-diam, keduanya diverifikasi lewat delivery sungguhan:\n\n**`payment_method_additional` adalah string JSON, bukan objek.** Akses titik mengembalikan `undefined` tanpa error:\n\n```ts\nconst extra = JSON.parse(String(history.payment_method_additional));\n\nextra.retail_code;   // \"ALFAMART\" — kode gerai untuk ditunjukkan pelanggan\nextra.partner_reff;  // referensi di sisi gerai\n```\n\n**Payment link yang sudah dibayar tidak pernah menyebut cara pembayarannya.** `data.payment.method` selalu literal `payment_link`. Channel sebenarnya — gerai retail, kartu, VA — hanya muncul di delivery `payment-link-inquiry` yang datang lebih dulu. Gabungkan keduanya lewat `reff_no`.\n\n**Nominal datang bertipe tidak konsisten antar event.** Diverifikasi dari delivery sungguhan pada hari yang sama:\n\n```\nva-transaction              amount: { value: \"125000.00\" }   <- string desimal\newallet-native-transaction  amount: { value: 55000 }         <- number\nqris-acquirer-transaction   amount: { value: 75000 }         <- number\n```\n\nJangan pernah membandingkan atau menjumlahkannya langsung. Selalu lewat `Amount.from()`, yang menerima kedua bentuk dan selalu mengembalikan integer:\n\n```ts\nAmount.from('125000.00').value; // 125000\nAmount.from(55000).value;       // 55000\n```\n\n**`transaction-expiration` adalah batch, bukan satu transaksi.** Payload-nya berisi tiga ember array sekaligus, dan bisa kosong:\n\n```ts\nconst { payment_link_histories, virtual_account_transactions, qris_histories } = verified.payload.data;\n```\n\nSapuannya berkala, bukan timer per-transaksi — jedanya bervariasi beberapa menit. **E-wallet tidak ikut tersapu sama sekali**; kegagalan atau kedaluwarsa e-wallet hanya bisa ditemukan lewat `inquireStatus()`.\n\n**Bentuk info tambahan juga berbeda antar event** — nama field-nya berbeda, dan tipenya berbeda. Yang satu string JSON, yang lain objek biasa:\n\n| Event | Field | Tipe |\n|---|---|---|\n| `payment-link-inquiry` | `payment_method_additional` | string JSON |\n| `ewallet-native-transaction` | `payment.additional_info` | objek |\n\nPeriksa tipenya sebelum membaca, jangan berasumsi dari satu event ke event lain.\n\n## Deploy di platform serverless\n\nSingaPay mewajibkan IP server terdaftar di dashboard dan menolak semua request dari IP lain dengan `SP017`. Vercel, Netlify, dan Cloudflare Workers memakai IP egress dinamis, jadi tidak ada yang bisa didaftarkan. Pilihannya:\n\n1. **Deploy di VPS ber-IP statis** (Coolify, Docker, Fly.io dengan dedicated IP). Paling sederhana.\n2. **Rutekan panggilan API lewat proxy ber-IP statis.** App tetap di Vercel; panggilan ke SingaPay lewat satu hop ber-IP tetap:\n\n   ```ts\n   const singapay = new SingaPay({\n     ...credentials,\n     baseUrls: { payment: { production: 'https://singapay-proxy.perusahaan-anda.id' } },\n   });\n   ```\n\n   Proxy-nya cukup meneruskan path, header, dan body **tanpa mengubah satu byte pun**.\n3. **Vercel Secure Compute** — berbayar, tingkat enterprise.\n\nWebhook masuk tidak terpengaruh: yang perlu di-whitelist adalah IP *keluar*, sementara webhook datang dari SingaPay ke Anda.\n\n## Nominal\n\nNominal wajib integer. Float ditolak sebelum ditandatangani, karena float bulat ter-serialisasi berbeda antar runtime (`100000.0` vs `100000`) dan merusak tanda tangan secara diam-diam.\n\n```ts\nimport { Amount } from '@aliziodev/singapay';\n\nAmount.rupiah(150_000).value;   // 150000\nAmount.from('150000.00').value; // 150000\nAmount.from('150000.50');       // InvalidAmountError\n```\n\n## Error\n\nSemua turunan `SingaPayError`:\n\n| Kelas | Kapan |\n|---|---|\n| `IpNotWhitelistedError` | SP017 — IP server tidak terdaftar |\n| `IndeterminateOutcomeError` | SP001 / SP005 — hasil tidak diketahui, wajib `inquireStatus()`. **Di endpoint kartu, SP001 juga dipakai untuk penolakan pasti** (`card_expiry` salah format, kuota harian habis) — baca pesannya sebelum memperlakukannya sebagai hasil ambigu |\n| `InsufficientBalanceError` | SP003 |\n| `DuplicateReferenceError` | SP004 |\n| `AccountCredentialRequiredError` | SP403 — panggil dengan kredensial pemilik akun. **Perhatikan: penolakan kredensial punya dua bentuk**, dan hanya satu membawa kode SP — yang lain `403` polos dengan pesan *\"Access denied to this account.\"* dan `code: null`, muncul sebagai `ApiError`. Bercabanglah pada `error.status === 403`, bukan pada kodenya |\n| `MoneyOutDisabledError` | Guard money-out masih mati |\n| `WebhookVerificationError` | Header kurang, timestamp basi, body kosong, atau tanda tangan tidak cocok |\n| `AuthenticationError` | Token exchange ditolak |\n| `ConnectionError` | Gateway tidak terjangkau |\n| `ApiError` | Kegagalan gateway lainnya |\n\n## Kenapa server-only\n\nSetiap request memerlukan `client_secret` untuk menandatangani. Begitu secret itu masuk bundle browser, siapa pun dapat membacanya lewat DevTools dan melakukan disbursement dari saldo merchant. Ini bukan hal yang bisa \"diperbaiki\" dengan memindahkan penandatanganan ke klien — itu justru menghancurkan seluruh model keamanannya.\n\nArsitektur yang benar untuk SPA React/Vue:\n\n```\nReact/Vue (browser) → backend Anda → @aliziodev/singapay → SingaPay\n```\n\nYang dikirim ke browser hanya artefak publik: `payment_url`, `qr_string`, `checkout_url`, `virtual_account_no`. Tidak pernah kredensial, tidak pernah tanda tangan.\n\nPenegakannya berlapis: export condition `browser` menunjuk ke modul yang langsung `throw`, dan konstruktor `SingaPay` melempar `BrowserUsageError` bila menemukan `window`. Di Next.js, tambahkan `import 'server-only'` di modul yang membuat client-nya bila ingin build gagal lebih awal.\n\n## Cakupan\n\n| Area | Status |\n|---|---|\n| Payment gateway API (money in & money out) | ✅ |\n| Verifikasi webhook + diskriminasi 13 event | ✅ |\n| Access token v1.1 (HMAC) & v1.0 (Basic) | ✅ |\n| Multiple credentials (*connections*) | ✅ |\n| Biller | ❌ belum |\n| Identity / KYC | ❌ belum |\n\nBiller dan Identity tidak ikut di v1 karena akun produksi yang tersedia tidak punya akses ke keduanya — kodenya tidak akan pernah bisa diverifikasi end-to-end, dan merilis kode yang tidak bisa dibuktikan jalan lebih buruk daripada tidak merilisnya. Keduanya menyusul di rilis minor berikutnya, di paket yang sama, tanpa breaking change.\n\nTiga seam sudah disiapkan supaya penambahannya nanti bersifat aditif, bukan breaking: `TokenProvider` adalah interface, `ServiceHost` sudah mencakup `biller` dan `identity` lengkap dengan base URL-nya, dan tidak ada satu pun `baseUrl` tunggal di mana pun.\n\n## Kanonikalisasi & signature vectors\n\nSingaPay tidak menerbitkan spesifikasi bagaimana body request diserialisasi sebelum ditandatangani. Aturannya harus disimpulkan dari perilaku gateway dan sampel resminya — key diurutkan **byte order** bukan UTF-16, objek kosong menjadi `[]`, unicode dan slash tidak di-escape, float ditolak. Salah satu saja, dan gateway menolak tanda tangannya tanpa memberi tahu alasannya.\n\n`test/fixtures/signature-vectors.json` mengunci semuanya: 18 vector, masing-masing menyimpan payload, JSON kanoniknya, hash SHA-256-nya, dan tanda tangan HMAC yang diharapkan untuk secret uji tetap. `pnpm test` menjalankan ketiga assertion itu untuk setiap vector.\n\nFixture ini **tidak boleh diedit tangan.** Kalau sebuah vector gagal, yang salah adalah normalizer-nya. Melonggarkan fixture supaya hijau hanya mengubah tanda tangan yang akan ditolak gateway menjadi test suite yang bilang semuanya beres.\n\nMenambah vector baru boleh dan dianjurkan ketika ada bentuk payload yang belum tercakup — hitung nilainya dari perilaku gateway, jangan dari output normalizer ini, karena vector yang diturunkan dari implementasi yang sedang diuji tidak membuktikan apa pun.\n\n## Pengembangan\n\n```bash\npnpm install\npnpm test         # vitest\npnpm typecheck    # tsc -b\npnpm lint         # biome\npnpm build        # tsdown\n```\n\nPaket ini **berjalan** di Node 20+, tapi **membangunnya** butuh Node 22+ — tsdown memakai `Promise.withResolvers`, yang baru ada di Node 22. CI memisahkan keduanya: test dijalankan di Node 20, 22, dan 24, sementara build dilakukan sekali lalu hasilnya dimuat ulang di Node 20 untuk membuktikan klaim `engines`.\n\nAda juga `pnpm test:e2e` yang memanggil sandbox SingaPay sungguhan. Suite itu skip sendiri tanpa kredensial dan tidak pernah dijalankan CI — lihat `.env.example`.\n\n## Lisensi\n\nMIT © Alizio\n","readmeFilename":"README.md"}