{"_id":"sendly-sdk","_rev":"6-738a5c9425d8330cf17dc0390d31cbff","name":"sendly-sdk","dist-tags":{"latest":"1.1.0"},"versions":{"0.2.0":{"name":"sendly-sdk","version":"0.2.0","keywords":["sendly","email","transactional-email","sdk","typescript","api-client","contacts","webhooks"],"license":"MIT","_id":"sendly-sdk@0.2.0","maintainers":[{"name":"devino-solutions","email":"amin@devino.ca"}],"homepage":"https://docs.sendly.now","bugs":{"url":"https://github.com/DevinoSolutions/sendly-js/issues"},"dist":{"shasum":"9b1ce4c2d0738d574cc103de1fe4b302832e1a72","tarball":"https://registry.npmjs.org/sendly-sdk/-/sendly-sdk-0.2.0.tgz","fileCount":7,"integrity":"sha512-wo/Ngtg5MySDBoQVZJwKSbmDyQWwmJ+c5yz4Vq5joFV2wzLi4ljoNijxmpWHfaKVpjFqa+UBY85Gc1kV5ckjaQ==","signatures":[{"sig":"MEYCIQDgRu1hi8mdCYLW81SeQ+ZU4chFF0xXdEfNjO0wUK3ihAIhAKY1wQ7/ANpzNoA3FdTX1TdH8k7dtFQQByPvRNOtC59R","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/sendly-sdk@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":345616},"main":"./dist/index.cjs","pnpm":{"overrides":{"vite":"8.0.10"}},"type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"gitHead":"758506223b2c033039dd98a6ce945b78f4adb937","scripts":{"lint":"eslint . --max-warnings=0","test":"vitest run","build":"tsup src/index.ts --format esm,cjs --dts --out-dir dist --clean","clean":"rimraf dist node_modules coverage","prebuild":"pnpm run build:types","sync-spec":"node scripts/sync-spec.mjs","build:types":"openapi-typescript ./openapi.json -o src/types.generated.ts","check-types":"tsc --noEmit","format:check":"prettier --check .","test:contract":"vitest run src/__tests__/contract.test.ts","check-spec-drift":"node scripts/check-spec-drift.mjs"},"_npmUser":{"name":"devino-solutions","email":"amin@devino.ca"},"repository":{"url":"git+https://github.com/DevinoSolutions/sendly-js.git","type":"git"},"_npmVersion":"11.16.0","description":"Official Sendly TypeScript SDK","directories":{},"sideEffects":false,"_nodeVersion":"24.18.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@9.15.9","devDependencies":{"tsup":"8.5.1","eslint":"10.6.0","rimraf":"6.1.3","vitest":"4.1.7","prettier":"3.9.4","typescript":"6.0.3","@types/node":"25.9.1","typescript-eslint":"8.60.0","openapi-typescript":"7.13.0"},"_npmOperationalInternal":{"tmp":"tmp/sendly-sdk_0.2.0_1785684394003_0.585403032011963","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"sendly-sdk","version":"0.3.0","keywords":["sendly","email","transactional-email","sdk","typescript","api-client","contacts","webhooks"],"license":"MIT","_id":"sendly-sdk@0.3.0","maintainers":[{"name":"devino-solutions","email":"amin@devino.ca"}],"homepage":"https://docs.sendly.now","bugs":{"url":"https://github.com/DevinoSolutions/sendly-js/issues"},"dist":{"shasum":"d037470daefe0719e8f9a9b8e51ab582e3f5ed7b","tarball":"https://registry.npmjs.org/sendly-sdk/-/sendly-sdk-0.3.0.tgz","fileCount":7,"integrity":"sha512-MHAlfW+F8Cxtr2AMqiZ6pY6G6NYRopjTIthEivqF7mN6joOxHDW6QaE9D+/wvwgwCMlvDUNlZPoKn7pbWfLKsg==","signatures":[{"sig":"MEUCIQClMAIz5XpJjTrjbaEWBugmtKxUrWITg8VYlQCv0neC5gIgMPvQx6H5uDoP7NOEupqgTalONyVR5qkKBTerMjh/kzk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/sendly-sdk@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":751166},"main":"./dist/index.cjs","pnpm":{"overrides":{"vite":"8.0.10"}},"type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"gitHead":"a4ac04f972db1a4403e35a153c94f2c94ebd0551","scripts":{"lint":"eslint . --max-warnings=0","test":"vitest run","build":"tsup src/index.ts --format esm,cjs --dts --out-dir dist --clean","clean":"rimraf dist node_modules coverage","prebuild":"pnpm run build:types","sync-spec":"node scripts/sync-spec.mjs","build:types":"openapi-typescript ./openapi.json -o src/types.generated.ts","check-types":"tsc --noEmit","format:check":"prettier --check .","test:contract":"vitest run src/__tests__/contract.test.ts","check-spec-drift":"node scripts/check-spec-drift.mjs"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:86942a85-d09f-4324-bfe9-3deca9d8eebd"}},"repository":{"url":"git+https://github.com/DevinoSolutions/sendly-js.git","type":"git"},"_npmVersion":"11.17.0","description":"Official Sendly TypeScript SDK","directories":{},"sideEffects":false,"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@9.15.9","devDependencies":{"tsup":"8.5.1","eslint":"10.6.0","rimraf":"6.1.3","vitest":"4.1.7","prettier":"3.9.4","typescript":"6.0.3","@types/node":"25.9.1","typescript-eslint":"8.60.0","openapi-typescript":"7.13.0"},"_npmOperationalInternal":{"tmp":"tmp/sendly-sdk_0.3.0_1787081706605_0.6289681143821164","host":"s3://npm-registry-packages-npm-production"}},"0.3.1":{"name":"sendly-sdk","version":"0.3.1","keywords":["sendly","email","transactional-email","sdk","typescript","api-client","contacts","webhooks"],"license":"MIT","_id":"sendly-sdk@0.3.1","maintainers":[{"name":"devino-solutions","email":"amin@devino.ca"}],"homepage":"https://docs.sendly.now","bugs":{"url":"https://github.com/DevinoSolutions/sendly-js/issues"},"dist":{"shasum":"3dfbf04b1b6d27dfb54aaf642b4a341669df3035","tarball":"https://registry.npmjs.org/sendly-sdk/-/sendly-sdk-0.3.1.tgz","fileCount":7,"integrity":"sha512-zqzxhB/gvFunjcPrzRZLIeiGRceFM6qEZIiVSTUdeLI7bbPPVcNDBgd6oklXr8a4Zp+6rvaw6/DAFmuAsGgXYg==","signatures":[{"sig":"MEYCIQCtzxdqIVI+Zb53JHvbY93UqjYN3/6P1VtzGYuZYx0PJgIhALDW27t/awD9uyHdWjRqQU/78xOcmPzkw2A+6LtbdykD","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/sendly-sdk@0.3.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":750378},"main":"./dist/index.cjs","pnpm":{"overrides":{"vite":"8.0.10"}},"type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"gitHead":"b7c6e06e94ba086883b15ba43ebaa814ececa458","scripts":{"lint":"eslint . --max-warnings=0","test":"vitest run","build":"tsup src/index.ts --format esm,cjs --dts --out-dir dist --clean","clean":"rimraf dist node_modules coverage","prebuild":"pnpm run build:types","sync-spec":"node scripts/sync-spec.mjs","build:types":"openapi-typescript ./openapi.json -o src/types.generated.ts","check-types":"tsc --noEmit","format:check":"prettier --check .","test:contract":"vitest run src/__tests__/contract.test.ts","check-spec-drift":"node scripts/check-spec-drift.mjs"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:86942a85-d09f-4324-bfe9-3deca9d8eebd"}},"repository":{"url":"git+https://github.com/DevinoSolutions/sendly-js.git","type":"git"},"_npmVersion":"11.17.0","description":"Official Sendly TypeScript SDK","directories":{},"sideEffects":false,"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@9.15.9","devDependencies":{"tsup":"8.5.1","eslint":"10.6.0","rimraf":"6.1.3","vitest":"4.1.7","prettier":"3.9.4","typescript":"6.0.3","@types/node":"25.9.1","typescript-eslint":"8.60.0","openapi-typescript":"7.13.0"},"_npmOperationalInternal":{"tmp":"tmp/sendly-sdk_0.3.1_1787095122952_0.8316693717176125","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"name":"sendly-sdk","version":"0.4.0","keywords":["sendly","email","transactional-email","sdk","typescript","api-client","contacts","webhooks"],"license":"MIT","_id":"sendly-sdk@0.4.0","maintainers":[{"name":"devino-solutions","email":"amin@devino.ca"}],"homepage":"https://docs.sendly.now","bugs":{"url":"https://github.com/DevinoSolutions/sendly-js/issues"},"dist":{"shasum":"f51d5039004121edee9b5518e03a3126cc2d6ef0","tarball":"https://registry.npmjs.org/sendly-sdk/-/sendly-sdk-0.4.0.tgz","fileCount":7,"integrity":"sha512-x2GfLW36avspaIkQjKAGBBqlgdfBcTiPyPn47pWhxS1jt3kX1TSknPs/ym9mCR5lmzC1aakiEphUOuvbh0RWUw==","signatures":[{"sig":"MEUCIQCfXX0Ru+YVsEnQxAYnYI0QrupP1YxV4v4uMtZLuBPk0wIgDthulm/teSl4Lrr5yS0LCq+oOKL6ShcoAlZ0iQ4Q2Ag=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/sendly-sdk@0.4.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":749330},"main":"./dist/index.cjs","pnpm":{"overrides":{"vite":"8.0.10"}},"type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"gitHead":"347cf5d67b611ee22954274fd3c80288ae6b0375","scripts":{"lint":"eslint . --max-warnings=0","test":"vitest run","build":"tsup src/index.ts --format esm,cjs --dts --out-dir dist --clean","clean":"rimraf dist node_modules coverage","prebuild":"pnpm run build:types","sync-spec":"node scripts/sync-spec.mjs","build:types":"openapi-typescript ./openapi.json -o src/types.generated.ts","check-types":"tsc --noEmit","format:check":"prettier --check .","test:contract":"vitest run src/__tests__/contract.test.ts","check-spec-drift":"node scripts/check-spec-drift.mjs"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:86942a85-d09f-4324-bfe9-3deca9d8eebd"}},"repository":{"url":"git+https://github.com/DevinoSolutions/sendly-js.git","type":"git"},"_npmVersion":"11.17.0","description":"Official Sendly TypeScript SDK","directories":{},"sideEffects":false,"_nodeVersion":"24.19.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@9.15.9","devDependencies":{"tsup":"8.5.1","eslint":"10.6.0","rimraf":"6.1.3","vitest":"4.1.7","prettier":"3.9.4","typescript":"6.0.3","@types/node":"25.9.1","typescript-eslint":"8.60.0","openapi-typescript":"7.13.0"},"_npmOperationalInternal":{"tmp":"tmp/sendly-sdk_0.4.0_1787238650709_0.8716781124619959","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"sendly-sdk","version":"1.0.0","keywords":["sendly","email","transactional-email","sdk","typescript","api-client","contacts","webhooks"],"license":"MIT","_id":"sendly-sdk@1.0.0","maintainers":[{"name":"devino-solutions","email":"amin@devino.ca"}],"homepage":"https://docs.sendly.now","bugs":{"url":"https://github.com/DevinoSolutions/sendly-js/issues"},"dist":{"shasum":"2d22cd654ad0b42d81901e5af3a34bf70b8e58c3","tarball":"https://registry.npmjs.org/sendly-sdk/-/sendly-sdk-1.0.0.tgz","fileCount":7,"integrity":"sha512-Xtmrser4SUYvGdJTnmfZFT2EKxNzysNis3QvPnaa8LdmhCqCJkdvhaP59xvBYvab8Ixn0l6ChoxjvMw0kBHKEQ==","signatures":[{"sig":"MEUCIC3/oB0xHNNXDI9DHSvP2ZGz3JIMfnFuMaH4ShtnHxbjAiEAliccCG+YS73oTUtIyxJXROMGLY9wOwuhxUAti/guP0M=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/sendly-sdk@1.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":948212},"main":"./dist/index.cjs","pnpm":{"overrides":{"vite":"8.0.10"}},"type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"gitHead":"0b2a79959fb64f939ec9da6e8cbbdcb9602be21c","scripts":{"lint":"eslint . --max-warnings=0","test":"vitest run","build":"tsup src/index.ts --format esm,cjs --dts --out-dir dist --clean","clean":"rimraf dist node_modules coverage","prebuild":"pnpm run build:types","sync-spec":"node scripts/sync-spec.mjs","build:types":"openapi-typescript ./openapi.json -o src/types.generated.ts","check-types":"tsc --noEmit","format:check":"prettier --check .","test:contract":"vitest run src/__tests__/contract.test.ts","check-spec-drift":"node scripts/check-spec-drift.mjs"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:86942a85-d09f-4324-bfe9-3deca9d8eebd"}},"repository":{"url":"git+https://github.com/DevinoSolutions/sendly-js.git","type":"git"},"_npmVersion":"11.19.0","description":"Official Sendly TypeScript SDK","directories":{},"sideEffects":false,"_nodeVersion":"24.20.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@9.15.9","devDependencies":{"tsup":"8.5.1","eslint":"10.6.0","rimraf":"6.1.3","vitest":"4.1.7","prettier":"3.9.4","typescript":"6.0.3","@types/node":"25.9.1","typescript-eslint":"8.60.0","openapi-typescript":"7.13.0"},"_npmOperationalInternal":{"tmp":"tmp/sendly-sdk_1.0.0_1788385570677_0.2678035775402676","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"sendly-sdk","version":"1.1.0","description":"Official Sendly TypeScript SDK","license":"MIT","type":"module","sideEffects":false,"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}}},"repository":{"type":"git","url":"git+https://github.com/DevinoSolutions/sendly-js.git"},"homepage":"https://docs.sendly.now","bugs":{"url":"https://github.com/DevinoSolutions/sendly-js/issues"},"keywords":["sendly","email","transactional-email","sdk","typescript","api-client","contacts","webhooks"],"publishConfig":{"access":"public"},"scripts":{"prebuild":"pnpm run build:types","build":"tsup src/index.ts --format esm,cjs --dts --out-dir dist --clean","build:types":"openapi-typescript ./openapi.json -o src/types.generated.ts","sync-spec":"node scripts/sync-spec.mjs","check-spec-drift":"node scripts/check-spec-drift.mjs","lint":"eslint . --max-warnings=0","format:check":"prettier --check .","check-types":"tsc --noEmit","test":"vitest run","test:contract":"vitest run src/__tests__/contract.test.ts","clean":"rimraf dist node_modules coverage"},"devDependencies":{"@types/node":"25.9.1","eslint":"10.6.0","openapi-typescript":"7.13.0","prettier":"3.9.4","rimraf":"6.1.3","tsup":"8.5.1","typescript":"6.0.3","typescript-eslint":"8.60.0","vitest":"4.1.7"},"engines":{"node":">=20"},"packageManager":"pnpm@9.15.9","pnpm":{"overrides":{"vite":"8.0.10"}},"gitHead":"93b22b5a36d481068ec84aa903e8a464c7caba17","_id":"sendly-sdk@1.1.0","_nodeVersion":"24.20.0","_npmVersion":"11.19.0","dist":{"integrity":"sha512-QYl42OUamiNikSyn1Ksqid80kQDPN3DJJzunL+b5nIPsrD+z8JFQ5PeKnzeeEgo8bG4vfjtHuZKdR3GQI07qxg==","shasum":"636e6a1f1d4a0e32350f18543a71a1fc46ed59d1","tarball":"https://registry.npmjs.org/sendly-sdk/-/sendly-sdk-1.1.0.tgz","fileCount":7,"unpackedSize":1730961,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/sendly-sdk@1.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIFPFZZY5b/rsZekkflUDE8TwuAYva5rjV9+diqlguDobAiEAj/ItUjSQvJk3+5B3jFIaaxDCQ2W7QiK9UCGmhNxqHuY="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:86942a85-d09f-4324-bfe9-3deca9d8eebd"}},"directories":{},"maintainers":[{"name":"devino-solutions","email":"amin@devino.ca"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/sendly-sdk_1.1.0_1788948296969_0.13819069195679012"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-02T15:26:33.763Z","modified":"2026-09-09T10:04:57.392Z","0.2.0":"2026-08-02T15:26:34.161Z","0.3.0":"2026-08-18T19:35:06.827Z","0.3.1":"2026-08-18T23:18:43.091Z","0.4.0":"2026-08-20T15:10:50.898Z","1.0.0":"2026-09-02T21:46:10.859Z","1.1.0":"2026-09-09T10:04:57.081Z"},"bugs":{"url":"https://github.com/DevinoSolutions/sendly-js/issues"},"license":"MIT","homepage":"https://docs.sendly.now","keywords":["sendly","email","transactional-email","sdk","typescript","api-client","contacts","webhooks"],"repository":{"type":"git","url":"git+https://github.com/DevinoSolutions/sendly-js.git"},"description":"Official Sendly TypeScript SDK","maintainers":[{"name":"devino-solutions","email":"amin@devino.ca"}],"readme":"# sendly-sdk\n\nOfficial TypeScript SDK for the [Sendly](https://sendly.now) REST API.\n\nType-safe email, contact, list, topic, domain, template, snippet, webhook and\nsuppression operations; mailbox reads plus the two composition calls a key may\ndrive; address validation and deliverability reporting; and the versioned\n`/api/v1` surface — campaigns, segments, workflows, analytics, and usage.\nGenerated from the public OpenAPI spec, so every endpoint and schema stays in\nsync.\n\n> This repository is the official standalone home and source of truth for the\n> Sendly TypeScript SDK — issues and PRs are welcome here. Its surface is\n> contract-tested against Sendly's public OpenAPI spec on every change, so the\n> client never drifts from the live API. Full docs live at\n> [https://docs.sendly.now](https://docs.sendly.now).\n\n## Install\n\n```bash\nnpm install sendly-sdk\n# or\npnpm add sendly-sdk\n```\n\nShips both ESM and CommonJS builds, so `import` and `require` both work.\n\nAlternatively, install the latest `main` directly from GitHub:\n\n```bash\nnpm install github:DevinoSolutions/sendly-js\n```\n\nRequires Node 20+ (or any runtime with global `fetch` and `AbortSignal.timeout`).\nThe API base is `https://api.sendly.now`; full docs live at\n[https://docs.sendly.now](https://docs.sendly.now).\n\n## Already on Resend, SendGrid, Postmark, Mailgun, or Plunk?\n\nYou don't even need this SDK to try Sendly. The API also speaks the\ntransactional-send dialect of those providers — keep the vendor SDK you already\nrun and change **two things**: the base URL and the API key.\n\n```ts\nimport { Resend } from \"resend\"; // your existing Resend integration\n\nconst resend = new Resend(\"sk_your_sendly_key\", {\n  baseUrl: \"https://api.sendly.now/api/compat/resend\",\n});\n// resend.emails.send(...) now sends through Sendly — same code, same shapes.\n```\n\nEvery compat request runs through the same pipeline as the native API (domain\nverification, suppression, limits), and anything a dialect can express that\nSendly doesn't support returns a clean error in that vendor's own error shape.\nPer-provider guides: [docs.sendly.now/migrate](https://docs.sendly.now/migrate).\n\n## Quick start\n\n```ts\nimport { Sendly } from \"sendly-sdk\";\n\nconst sendly = new Sendly({ apiKey: process.env.SENDLY_API_KEY! });\n\nconst receipt = await sendly.emails.send({\n  from: \"hello@your-domain.com\",\n  to: \"user@example.com\",\n  subject: \"Welcome to Acme\",\n  body: \"<p>Glad to have you.</p>\",\n});\nconsole.log(receipt.id, receipt.status); // status is a real delivery state\n```\n\n## Upgrading from 1.0\n\n1.1 is mostly additive — four new resources and the `/api/v1` half of six more —\nbut it also tracks a set of **wire-visible renames** that landed in the platform,\nso it is a breaking release. Do not deploy 1.1 against an API that has not taken\nthe renamed wire yet: the old field names are gone from these types, and sending\n`type` where the API now expects `emailCategory` is a `422`, not a shrug.\n\nWhat to change, in the order a codebase usually hits it:\n\n- **`type` → `emailCategory` (legacy) / `email_category` (v1)** on templates and\n  campaigns. Affects `templates.create`, `templates.update`, the `emailCategory`\n  filter on `templates.list`, and `campaigns.create`. The enum member `HEADLESS`\n  is now `SELF_MANAGED_UNSUBSCRIBE`; `MARKETING` and `TRANSACTIONAL` are\n  unchanged.\n- **`data` → `payload`** in the body of `events.record` (the v1 write). The\n  legacy `events.track` is untouched and still takes `data` — the two endpoints\n  were renamed on different schedules, and this SDK reports what each one\n  actually accepts rather than papering over the difference.\n- **`mailFromStatus` → `mailFromDomainStatus`** on a domain, and\n  `mail_from_domain_status` on the v1 document.\n- **`emails.get` returns a different body** — the one change here worth reading\n  in full; see below.\n- **`EmailGetResponse` is gone.** It named the operation rather than the shape,\n  and was then reused by an operation that is not a GET. It is now two types:\n  `EmailResponse` (a single email) and `EmailDetailResponse` (an email plus its\n  delivery events), and `emails.get` resolves the latter.\n- **The double-opt-in confirmation route moved** from `/api/lists/confirm` to\n  `/api/lists/confirm-subscription`. Sendly has never sent that email for you, so\n  if you build the URL yourself — and `lists.subscribe` is documented on the\n  assumption that you do — change the path.\n\n### `emails.get`, specifically\n\nIt used to hand back the whole database row together with an `events` array that\nwas the **wrong relation**: the custom analytics events a caller records with\n`events.record`, not the delivery history the operation has always promised.\n\nIt now returns an explicit field list plus `events` as the delivery timeline\n(`EmailEvent[]`, oldest first), and it fills `to` from the joined contact — which\nthe spec had always declared and the response had never carried.\n\nFields that used to leak out of it and no longer do: `bodyHash`, `dedupKey`,\n`idempotencyKey`, `linkMap`, `sesMessageId`, `sesInboundMessageId`, `body` and\n`headers`. Four of those are ledger keys for deduplication and idempotency; the\nrest are internal routing state or the rendered message itself. None of them were\never documented, and a caller reading them was reading Sendly's bookkeeping.\n\nIf you were reading `events` from this call expecting custom events, read\n`events.list` instead. If you were reading the message body back out of it, keep\nyour own copy — it is not published here.\n\n### Engagement left the delivery status\n\n`OPENED`, `CLICKED` and `COMPLAINED` are no longer delivery statuses on the\nplatform, and the SDK's own status type — the enum behind `email.status` and the\n`status` filter on `emails.list` — no longer offers them. A message is\n`PENDING`, `SENDING`, `SENT`, `DELIVERED`, `RECEIVED`, `BOUNCED`, `FAILED`,\n`REJECTED`, `RENDERING_FAILURE`, `DELIVERY_DELAY` or `CANCELLED`. Engagement is a\nseparate axis, read from `openedAt` / `clickedAt` / `complainedAt` and the\n`opens` / `clicks` counters on the email itself:\n\n```ts\nconst { data: email } = await sendly.emails.get(id);\nconst delivered = email.status === \"DELIVERED\"; // a delivery fact\nconst engaged = email.openedAt !== null || email.clicks > 0; // an engagement fact\n```\n\nThe two used to be one enum, which meant an opened message stopped reporting that\nit had been delivered.\n\n## Upgrading from 0.x\n\n**1.0 repoints `emails.send` to the versioned `POST /api/v1/emails`.** It now\ntakes one recipient (`cc`/`bcc` copy others) and resolves the `202` receipt\n`{ id, status, to, from }`, where `status` is a real delivery state. Before 1.0\nit posted to the legacy `POST /api/emails`, fanned an array `to` out to several\nrecipients, and resolved `{ emails, timestamp }` with no delivery status.\n\nThe old behaviour is kept, unchanged, as `emails.sendLegacy`. Two ways to\nupgrade:\n\n- **Keep the old shapes:** rename the call. `send(...)` → `sendLegacy(...)`.\n  Done.\n- **Take the new default:** read the receipt instead of the envelope\n  (`receipt.id` / `receipt.status` in place of `result.emails[0].email`), send\n  to one recipient per call, and note that failures now carry the v1 error\n  fields (`errorCode` is lowercase, `requestId` and `fieldErrors` are set) —\n  the `SendlyError` subclasses are the same, so `instanceof` checks stand.\n\nNothing else changed shape. See [CHANGELOG.md](./CHANGELOG.md) for the full\n1.0.0 entry.\n\n## Authentication\n\nPass a project API key. `sk_*` keys allow full access; `pk_*` keys are\nsending-only. Keys are sent in the `Authorization: Bearer <key>` header\non every request.\n\n```ts\nconst sendly = new Sendly({\n  apiKey: \"sk_live_...\", // required\n  baseUrl: \"https://api.sendly.now\", // optional, override for staging / self-hosted\n  timeout: 30_000, // ms, optional (default 30s)\n});\n```\n\n## The resources\n\nEvery resource hangs off the client. A `V1` suffix means the method speaks the\nversioned dialect; an unsuffixed method on the same resource speaks the legacy\none. See [Both dialects, one client](#both-dialects-one-client) for why both are\nhere.\n\n| `sendly.*`       | Methods                                                                                                                                                                                             |\n| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `emails`         | `send`, `sendLegacy`, `sendTest`, `batch`, `list`, `get`, `cancelSchedule`                                                                                                                          |\n| `contacts`       | `create`, `upsert`, `bulkCreate`, `bulkDelete`, `list`, `get`, `update`, `delete`, `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, `deleteV1`, `topicPreferences`                           |\n| `lists`          | `subscribe`, `unsubscribe`, `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, `deleteV1`, `startValidationRun`                                                                                |\n| `topics`         | `list`, `listAll`, `create`, `get`, `update`, `setSubscription`                                                                                                                                     |\n| `templates`      | `create`, `list`, `get`, `update`, `delete`, `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, `deleteV1`                                                                                     |\n| `snippets`       | `create`, `list`, `get`, `update`, `delete`                                                                                                                                                         |\n| `domains`        | `create`, `list`, `get`, `verify`, `getVerification`, `startSetup`, `assignStream`, `delete`, `listV1`, `listAllV1`, `createV1`, `getV1`, `verifyV1`, `deleteV1`                                    |\n| `webhooks`       | `create`, `list`, `get`, `update`, `delete`, `rotateSecret`, `listCalls`, `listV1`, `listAllV1`, `createV1`, `getV1`, `updateV1`, `deleteV1`, `rotateSecretV1`                                      |\n| `suppression`    | `add`, `list`, `get`, `remove`, `listV1`, `listAllV1`, `createV1`, `getV1`, `deleteV1`                                                                                                              |\n| `events`         | `track`, `record`, `list`, `listAll`, `listNames`, `stats`                                                                                                                                          |\n| `campaigns`      | `list`, `listAll`, `create`, `get`, `update`, `delete`, `send`, `cancel`, `pause`, `resume`, `stats`, `listFailures`, `listFailuresAll`, `retryFailed`                                              |\n| `segments`       | `list`, `listAll`, `create`, `get`, `update`, `delete`, `listContacts`, `listContactsAll`                                                                                                           |\n| `workflows`      | `list`, `listAll`, `create`, `get`, `update`, `delete`, `listExecutions`, `listExecutionsAll`, `startExecution`, `cancelExecution`, `stats`, `getGraph`, `replaceGraph`, `clone`, `pause`, `resume` |\n| `mailboxes`      | `list`, `get`, `listAppPasswords`, `sendMessage`, `draftMessage`                                                                                                                                    |\n| `validation`     | `validateEmails`, `getRun`, `listResults`, `listResultsAll`                                                                                                                                         |\n| `deliverability` | `diagnose`, `listDomainStats`, `listDomainStatsAll`, `listDmarcReports`, `listDmarcReportsAll`                                                                                                      |\n| `analytics`      | `timeseries`, `campaigns`, `topCampaigns`                                                                                                                                                           |\n| `usage`          | `get`                                                                                                                                                                                               |\n| `projects`       | `get`                                                                                                                                                                                               |\n| `verify`         | `email`                                                                                                                                                                                             |\n\n## Common operations\n\n### Send a single email\n\n```ts\nconst receipt = await sendly.emails.send(\n  {\n    from: \"hello@your-domain.com\",\n    to: \"user@example.com\", // one recipient; `cc` / `bcc` copy others\n    subject: \"Order confirmed\",\n    body: \"<p>Thanks for your order.</p>\",\n  },\n  { idempotencyKey: \"order-confirm-12345\" }, // optional, replays deduped 24h\n);\n\n// `receipt` is `{ id, status, to, from }`, answered with 202. `status` is a real\n// delivery state — poll `emails.get(receipt.id)` for the events behind it.\nconsole.log(receipt.id, receipt.status);\n```\n\nThe pre-1.0 send — the legacy `POST /api/emails`, which fans an array `to` out\nto several recipients and answers `{ emails, timestamp }` with no delivery\nstatus — is still here as `emails.sendLegacy`:\n\n```ts\nconst result = await sendly.emails.sendLegacy({\n  from: \"hello@your-domain.com\",\n  to: [\"a@example.com\", \"b@example.com\"],\n  subject: \"Order confirmed\",\n  body: \"<p>Thanks for your order.</p>\",\n});\n// One `emails` entry per recipient: `{ contact: { id, email }, email }`, where\n// `email` is the id of the queued email record. Poll `emails.get(id)` for status.\nconsole.log(\n  result.emails.map((entry) => entry.email),\n  result.timestamp,\n);\n```\n\n### Read an email and its delivery history\n\n```ts\nconst { data: email } = await sendly.emails.get(receipt.id);\n\nconsole.log(email.to, email.status, email.opens, email.clicks);\n\n// `events` is the DELIVERY timeline behind `status`, oldest first — not the\n// custom events you record with `events.record`, which are read from\n// `events.list`.\nfor (const event of email.events) {\n  console.log(event.timestamp, event.status);\n}\n```\n\nThis is one of the few legacy reads the SDK hands back enveloped rather than\nunwrapped, so the email is under `.data`.\n\n### List emails with filters and cursor pagination\n\n```ts\nconst page = await sendly.emails.list({ limit: 20, tag: \"welcome\", status: \"DELIVERED\" });\nfor (const email of page.data) {\n  console.log(email.id, email.to, email.status);\n}\nif (page.nextCursor) {\n  const next = await sendly.emails.list({ limit: 20, cursor: page.nextCursor });\n}\n```\n\n`status` filters on the delivery lifecycle only. To find the messages somebody\nopened, read `openedAt` / `opens` on the rows — engagement is not a status.\n\n### Upsert a contact\n\n```ts\nconst contact = await sendly.contacts.upsert({\n  email: \"user@example.com\",\n  customFields: { plan: \"pro\", signedUpAt: new Date().toISOString() },\n});\n```\n\nThe v1 half of the resource manages the same contacts with snake_case bodies and\ncursor pagination — `contacts.listV1`, `createV1`, `getV1`, `updateV1`,\n`deleteV1`, and `contacts.listAllV1` to walk every page:\n\n```ts\n// `subscribed` is the string \"true\" / \"false\" here, not a boolean — it is a\n// query parameter with three states, and omitting it means \"both\".\nfor await (const contact of sendly.contacts.listAllV1({ subscribed: \"true\" })) {\n  console.log(contact.email, contact.custom_fields);\n}\n```\n\nTwo things about `contacts.updateV1` catch people out: `email` is not patchable\nat all (an address is the contact's identity, and rewriting it in place would\nchange who every earlier send was addressed to), and `custom_fields` is\n**replaced, not merged** — send back every key you mean to keep.\n\n### Read one contact's consent\n\n```ts\nconst prefs = await sendly.contacts.topicPreferences(contact.id);\n\n// `prefs.subscribed` is the global marketing opt-out and OUTRANKS every topic:\n// false means nothing marketing reaches them whatever the rows below say.\nfor (const topic of prefs.topics) {\n  console.log(topic.key, topic.subscribed, topic.pending);\n}\n```\n\nEach topic's `subscribed` is the effective answer the send path reaches today,\nwith the topic's `default_opt_in` already folded in, so a contact who has never\nanswered still reads correctly.\n\n### Manage domains\n\n```ts\nconst domain = await sendly.domains.create({ domain: \"mail.your-domain.com\" });\n// Publish each token as a CNAME record before verification can succeed.\nconsole.log(domain.dkimTokens);\n\nawait sendly.domains.verify(domain.id);\n\nconst status = await sendly.domains.getVerification(domain.id);\n// One status per record type, not one verdict for the domain.\nconsole.log(status.dkimStatus, status.spfStatus, status.dmarcStatus);\n```\n\nPass `region` to pin the domain to an SES region (`us-east-1`, `us-west-2` or\n`eu-west-1`). The first domain locks the project's region; later ones must match\nit.\n\nA domain reports each DNS record type separately — `dkimStatus`, `spfStatus` and\n`dmarcStatus` are each `NOT_CHECKED`, `PENDING`, `VERIFIED` or `FAILED`, and\n`lastHealthCheckAt` says when they were last filled. `status` on the verification\nresponse is a different thing: SES's own raw DKIM state (`Success`, `Pending`),\nwhich is why both are published rather than collapsed into one. `receivingEnabled`\nsays whether inbound mail for the domain is routed to Sendly mailboxes.\n\nPublishing the DNS records by hand is not the only route. `startSetup` opens the\nguided hand-off and returns the session exactly as the API returns it:\n\n```ts\nconst session = await sendly.domains.startSetup(domain.id);\n// { token, connectUrl, expiresAt } — connectUrl is short-lived and domain-specific.\nconsole.log(\"finish setup at\", session.connectUrl, \"before\", session.expiresAt);\n```\n\nNothing here is reshaped, because finishing setup means a **person** opening\n`connectUrl` and authorising the change at their registrar. The SDK's job is to\nhand back the link, not to model the flow behind it.\n\n`assignStream` points a verified identity at one kind of traffic:\n\n```ts\nawait sendly.domains.assignStream(domain.id, {\n  stream: \"TRANSACTIONAL\",\n  streamDefault: true,\n  defaultFromAddress: \"receipts@mail.your-domain.com\",\n});\n```\n\nStreams are enforced, not labelled: once assigned, a send of the other kind from\nthis identity is refused with `403` — which is what keeps a campaign's complaint\nrate off the identity your password resets go out on. `stream: null` unassigns\nit, returning it to carrying both. `streamDefault` demotes whichever identity\ncurrently holds the default for that stream, and `defaultFromAddress` has to be\nan address on this identity's own host.\n\n### Mailboxes: read, send, and draft\n\nReceiving mailboxes on the project's verified domains. The reads are reads; the\ntwo composition calls are not — `sendMessage` really sends.\n\n```ts\nconst mailboxes = await sendly.mailboxes.list(); // not paginated\nconst mailbox = await sendly.mailboxes.get(mailboxes[0].id);\n\n// `settings` carries the IMAP and SMTP host, port, security and username.\nconsole.log(mailbox.settings.imap.host, mailbox.settings.imap.port);\n\n// App passwords, metadata only — `lastFour` is the one fragment of the secret\n// that survives creation, so a credential can be identified but not rebuilt.\nfor (const pw of await sendly.mailboxes.listAppPasswords(mailbox.id)) {\n  console.log(pw.name, pw.lastFour, pw.lastUsedAt);\n}\n```\n\nThis lists the mailboxes themselves, never their contents — received messages\nare not part of the public API. The mailbox **password** is never returned by\nany of these reads; mailbox credentials are app passwords, created from the\ndashboard and shown once. `listAppPasswords` returns only the passwords that are\nstill active — a revoked one drops out, so this is not an audit history.\n\n**`sendMessage` sends real mail**, from the mailbox in the path, over its own\ndomain, and the recipient can reply to it:\n\n```ts\nconst sent = await sendly.mailboxes.sendMessage(mailbox.id, {\n  to: [\"customer@example.com\"],\n  subject: \"Re: your order\",\n  body: \"Shipping tomorrow — tracking to follow.\",\n});\nconsole.log(sent.conversationId, sent.messageId);\n```\n\nThere is no `from` field, on purpose: a route that sends under a customer's own\nidentity must not take that identity as an argument. `body` is plain text and\nHTML is refused — Sendly renders the HTML part itself, escaping as it goes, so\ntext becomes markup in exactly one place. Bcc recipients are delivered to but\nappear in no header, so the copy filed in the Sent folder does not record them.\nRefusals worth handling by name: `422 RECIPIENT_SUPPRESSED`,\n`422 CONTENT_REFUSED`, and `503 CONTENT_SCAN_UNAVAILABLE` (no verdict yet for a\nyoung project — nothing was sent, retry shortly). A mailbox may send 60 messages\nan hour here.\n\n**`draftMessage` sends nothing.** It asks Sendly's assistant to write text and\nhands it back for you to review:\n\n```ts\nconst draft = await sendly.mailboxes.draftMessage(mailbox.id, {\n  mode: \"draft\", // or \"rewrite\", or \"subject\"\n  brief: \"Tell the customer their order ships tomorrow and apologise for the delay.\",\n  tone: \"apologetic\",\n});\nconsole.log(draft.subject, draft.body, draft.sent); // sent is always false\n```\n\n`sent: false` is reported rather than assumed, so a draft cannot be mistaken for\na send. It stores nothing, reads no correspondence, and needs only\n`mailboxes:read` where sending needs `mailboxes:send` — a client that may draft\nis not thereby a client that may mail your customers. Everything you pass is\ntreated strictly as data describing what to write, never as instructions to the\nmodel. Capped at 120 requests an hour per project; `502` means the model was\nunreachable.\n\n### Templates and snippets\n\n```ts\nconst template = await sendly.templates.create({\n  name: \"Welcome\",\n  subject: \"Welcome to Acme\",\n  body: \"<p>Hi {{ name }}</p>{{> footer }}\",\n  from: \"hello@your-domain.com\",\n  emailCategory: \"MARKETING\", // was `type` before 1.1\n});\n```\n\n`emailCategory` is `MARKETING`, `TRANSACTIONAL` or `SELF_MANAGED_UNSUBSCRIBE`\n(the member that used to be called `HEADLESS`). It defaults to `MARKETING` and\nis also the legacy list filter: `templates.list({ emailCategory: \"MARKETING\" })`.\n\nA template carries `currentVersion`, a counter an update increments only when it\nchanges the **rendered content** — a rename leaves it alone. A campaign records\nthe version it sent, so comparing the two is how you tell \"the template changed\nsince this went out\" from \"somebody retitled it\".\n\nA **snippet** is a reusable fragment a template pulls in with `{{> name}}`.\n`name` is the literal identifier templates include, unique within the project, so\na clash answers `409`:\n\n```ts\nawait sendly.snippets.create({\n  name: \"footer\",\n  description: \"Address block and unsubscribe line\",\n  body: \"<hr /><p>Acme Inc, 1 Example Way</p>\",\n});\n\nconst page = await sendly.snippets.list({ limit: 25, search: \"footer\" });\nconsole.log(page.data.data.length, page.data.hasMore);\n```\n\nSnippets are gated by the same `templates:*` scopes as the templates that include\nthem, because a snippet is part of a template body rather than a resource with an\naudience of its own. Deleting one does not break the templates that include it —\nan absent snippet renders as an empty string, like an absent variable.\n\n### Consent: topics\n\nA topic is the subject a project mails about — a contact subscribes to a topic\nrather than to a campaign, so switching one off silences a whole audience.\n\n```ts\nconst topic = await sendly.topics.create({\n  key: \"product-updates\", // stable; survives a rename of `name`, and is not patchable\n  name: \"Product updates\",\n  default_opt_in: true,\n});\n\nconst result = await sendly.topics.setSubscription(topic.id, {\n  contact_id: contact.id,\n  subscribed: true,\n});\n```\n\n**Subscribing somebody through the API does not bypass confirmation.**\n`subscribed: true` parks the contact at `pending` and answers a\n`confirmation_url`; nothing is mailed on this topic until someone opens that\nlink, and there is no parameter to skip it — a subscription a caller asserts is\nnot evidence the mailbox holder agreed. Sendly does not send that email; **your\napplication** delivers `result.confirmation_url`, from your own verified domain.\n`subscribed: false` records the opt-out immediately.\n\n`default_opt_in` decides what silence means for a contact who never answers: true\nfor a topic introduced over a list that already consented to hear from you, false\nfor anything a person has to ask for.\n\nThere is no `topics.delete`. A topic is where people's answers are recorded, so\ndeleting it would delete the choices they made; `update(id, { archived: true })`\nis the retire button and drops it from the preference centre and from new sends\nwhile every opt-out survives. `list({ include_archived: true })` brings them back.\n\n### Validate addresses before you mail them\n\n**Every address checked is billed.** Looping this over a contact list is looping\nover your invoice.\n\n```ts\nconst batch = await sendly.validation.validateEmails({\n  emails: [\"user@example.com\", \"typo@exmaple.com\"], // at most 50 per call\n});\n\nfor (const result of batch.results) {\n  // Branch on `verdict`, never on the flags: `is_personal` (Gmail, Outlook) and\n  // `is_role_address` (`support@`) describe ordinary, deliverable addresses.\n  console.log(result.email, result.verdict);\n}\n```\n\nThe 50-address ceiling is a latency bound, not a payload one: every distinct\ndomain in the batch costs a DNS round trip. To check a whole list, start the\nbackground run instead — one call, then poll:\n\n```ts\nconst run = await sendly.lists.startValidationRun(list.id);\n\nconst progress = await sendly.validation.getRun(run.id);\n// Finished when `status` is \"completed\" or \"failed\" — never when a percentage\n// reaches 100, because there is deliberately no total to divide by: a list\n// changes size while a run walks it.\nconsole.log(progress.status, progress.processed_count, progress.undeliverable_count);\n\nfor await (const result of sendly.validation.listResultsAll(run.id, { verdict: \"undeliverable\" })) {\n  console.log(result.email, result.contact_id, result.reasons);\n}\n```\n\nA verdict of `unknown` is deliberately a separate value from `undeliverable`: it\nmeans DNS did not answer in time, so that address was **not checked**. Deleting a\ncontact on `unknown` deletes a live one over a network hiccup. `undeliverable` is\nthe page to read before acting on a run; `unknown` is the one never to act on.\n\n### Diagnose deliverability\n\n```ts\nconst diagnosis = await sendly.deliverability.diagnose({\n  domain: \"mail.your-domain.com\", // required — this endpoint answers about one domain\n  address: \"user@example.com\", // optional RECIPIENT to check alongside it\n  window_days: 7,\n});\n\n// `findings` is worst first, and an empty array means nothing here explains a\n// delivery problem. Branch on a finding's `code`, never on its prose.\nfor (const finding of diagnosis.findings) {\n  console.log(finding.severity, finding.code);\n}\n```\n\nNothing there is looked up live: the DNS statuses are the verification refresh\njob's cached results, and `identity.last_checked_at` says when they were filled.\n`recent_delivery` is project-wide rather than per-domain — its own `scope` field\nsays so — because an email row records no sending domain.\n\n`listDomainStats` is the axis `diagnose` cannot report: outcomes broken out by\n**recipient** domain and UTC day. These are the domains you send **to** —\n`gmail.com`, `outlook.com` — not the domains you send from, and they are how you\ncatch one provider refusing nearly everything while the rest of your mail is\nhealthy.\n\n```ts\nfor await (const row of sendly.deliverability.listDomainStatsAll({ limit: 100 })) {\n  console.log(row.day, row.domain, row.delivered, row.bounced, row.computed_at);\n}\n```\n\nThe counts come from an hourly rollup over a rolling 30-day window, not from a\nquery run on request; each row's `computed_at` says when it was last rebuilt. No\nrate is published, because a rate over three sends is not information.\n\n`listDmarcReports` returns the DMARC aggregate (RUA) reports receiving providers\nhave sent about your domains. **An empty list is the correct answer, not a bug**,\nuntil a policy domain is registered in this project and its DMARC record names an\naddress we receive — and receivers send on their own schedule, typically once a\nday.\n\n```ts\nconst reports = await sendly.deliverability.listDmarcReports({ limit: 20 });\n\n// Which kind of empty is this? `false` means no intake mailbox exists, so no\n// report can ever arrive — the feature is off, your domains are not \"clean\".\nif (!reports.intake_configured) {\n  console.warn(\"DMARC report intake is not configured on this deployment\");\n}\n\nfor (const report of reports.data) {\n  console.log(report.org_name, report.policy_domain, report.pass_count, report.fail_count);\n}\n```\n\n`intake_configured` exists because the two empty lists are otherwise\nindistinguishable, and reporting \"no DMARC failures\" off a feature that was\nnever switched on is the worse of the two mistakes. Read the flag before you\ntell anyone the domains are healthy.\n\n`pass_count` counts DMARC **alignment** taken from `policy_evaluated`, not raw\nauthentication results — a message can pass SPF for a domain that is not the one\nin its From header, which is exactly the case DMARC exists to catch.\n\n### Subscribe a webhook\n\n```ts\nconst created = await sendly.webhooks.create({\n  url: \"https://your-app.com/webhooks/sendly\",\n  eventTypes: [\"email.delivered\", \"email.bounced\", \"email.complained\"],\n});\n// store `created.data.secret` securely — used to verify HMAC signatures.\n// The endpoint is beside it rather than spread around it: `created.data.webhook.id`.\n```\n\nA webhook record carries `domains` — the sending domains this endpoint is scoped\nto, where an empty array means every domain on the project — and, while a\nrotation is in flight, `previousSecretExpiresAt`. A record never carries a\nsecret or any fragment of one.\n\nOn v1 the same registration resolves the secret beside the webhook, and adds\nrotation:\n\n```ts\nconst { webhook, secret } = await sendly.webhooks.createV1({\n  url: \"https://your-app.com/webhooks/sendly\",\n  event_types: [\"email.delivered\", \"email.bounced\"],\n});\n\nconst rotated = await sendly.webhooks.rotateSecretV1(webhook.id);\nconsole.log(rotated.secret, rotated.previous_secret_expires_at);\n```\n\n`createV1` and `rotateSecretV1` are the only two responses that ever carry a\nsigning secret; no read endpoint hands it back, so a secret you lose is replaced\nby rotating rather than recovered. The outgoing secret is not cut off at once —\nit keeps verifying until `previous_secret_expires_at`, and every delivery inside\nthat window carries **both** signatures, so a verifier can be redeployed without\ndropping an event.\n\n### Add to the suppression list\n\n```ts\nawait sendly.suppression.add({ email: \"angry@example.com\", reason: \"MANUAL\" });\n\n// Alone among the legacy reads, this one answers no `{ success, data }`\n// envelope — the page IS the body.\nconst page = await sendly.suppression.list({ reason: \"MANUAL\", limit: 100 });\nfor (const record of page.items) {\n  console.log(record.email, record.reason, record.scope);\n}\n```\n\n`scope` is `PROJECT` on every record this API creates or returns today; `GLOBAL`\nis reserved for a platform-wide block recorded outside your project.\n\nThe v1 half addresses a record by the **address itself** and answers definitively\neither way — `200` means suppressed and says why, `404 resource_not_found` means\nit is not on the list. That is the difference from the legacy `suppression.get`,\nwhich answers `200 { suppressed: false }` for an address nobody suppressed:\n\n```ts\nimport { SendlyNotFoundError } from \"sendly-sdk\";\n\ntry {\n  const record = await sendly.suppression.getV1(\"angry@example.com\");\n  console.log(\"suppressed:\", record.reason, record.source);\n} catch (err) {\n  if (err instanceof SendlyNotFoundError) {\n    // not suppressed — mail may flow\n  } else throw err;\n}\n```\n\nSuppressing is idempotent and the first `reason` wins: an already-suppressed\naddress answers with the existing record, so a later manual entry cannot\noverwrite what an SES bounce recorded. `source` is not accepted in the body — it\nis derived from the credential, so a record's provenance cannot be dressed up as\na deliverability fact.\n\n`deleteV1` is the one call on this surface that can put mail back into an inbox\nthat asked you to stop, and it does **not** clear AWS SES's own account-level\nsuppression list: an address SES suppressed after a hard bounce stays\nundeliverable through SES even once this record is gone.\n\n### Track a custom event\n\nRecords a custom event against a contact. The legacy `events.track` works with\nboth `sk_*` and `pk_*` keys (reserved system event names are rejected) and\ncarries its payload in `data`:\n\n```ts\nconst tracked = await sendly.events.track({\n  event: \"purchase.completed\",\n  email: \"user@example.com\",\n  data: { plan: \"pro\", amount: 4900 },\n});\nconsole.log(tracked.contact, tracked.event);\n```\n\n`events.record` is the same capability on `/api/v1/events`, and its payload field\nis called `payload`:\n\n```ts\nconst event = await sendly.events.record({\n  name: \"purchase.completed\",\n  contact_id: contact.id, // must already exist — this endpoint never creates contacts\n  payload: { plan: \"pro\", amount: 4900 },\n});\n```\n\nNew integrations should prefer `record`, which also unlocks `events.list`,\n`events.listNames` and `events.stats`.\n\n### Verify an email address\n\n```ts\nconst check = await sendly.verify.email({ email: \"user@example.com\" });\nif (!check.valid) {\n  console.log(\"rejecting\", check.reason);\n}\n```\n\nThis is the free single-address syntax/MX check. It is not\n`validation.validateEmails`, which is the billed batch check with a verdict\nvocabulary behind it.\n\n## The `/api/v1` surface\n\nCampaigns, segments, workflows, analytics, usage, topics, validation,\ndeliverability and events live on Sendly's versioned API, as does the `V1` half\nof contacts, lists, templates, domains, webhooks and suppression. They hang off\nthe same client and the same base URL, but they speak a different dialect from\nthe `/api/*` resources above:\n\n- **Responses are the bare resource**, not a `{ success, data }` envelope, and\n  fields are `snake_case`.\n- **Errors are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem\n  documents** (`application/problem+json`) — see below.\n- **Lists are cursor-paginated only** — `{ data, has_more, next_cursor }`, no\n  total.\n\n```ts\nconst campaign = await sendly.campaigns.create(\n  {\n    name: \"August launch\",\n    subject: \"We shipped it\",\n    body: \"<p>Read all about it.</p>\",\n    from: \"hello@your-domain.com\",\n    audience_type: \"SEGMENT\",\n    segment_id: segment.id,\n  },\n  { idempotencyKey: `launch-${releaseId}` },\n);\n\n// Creating never sends. Send now, or schedule it:\nawait sendly.campaigns.send(campaign.id, { scheduled_for: \"2026-09-01T10:00:00Z\" });\n\nconst stats = await sendly.campaigns.stats(campaign.id);\nconsole.log(stats.delivered, stats.open_rate);\n```\n\n### Both dialects, one client\n\nSix resources — contacts, lists, templates, domains, webhooks and suppression —\nnow answer on both surfaces, so their v1 methods carry a `V1` suffix:\n`contacts.list` is the legacy one, `contacts.listV1` the versioned one.\n\nThe suffix is not decoration. The two methods answer the same question with\ndifferent envelopes, different field cases and different error bodies, and a call\nsite that mixes them up reads a `data` that is not there:\n\n```ts\nconst legacy = await sendly.contacts.list({ limit: 20 });\nlegacy.data.data; // Contact[] — inside the `{ success, data }` envelope\nlegacy.data.nextCursor; // camelCase\n\nconst v1 = await sendly.contacts.listV1({ limit: 20 });\nv1.data; // ContactV1[] — the bare body IS the list envelope\nv1.next_cursor; // snake_case\n```\n\nLegacy methods keep working and nothing about them changed in 1.1. New code\nshould reach for the `V1` ones: they are the surface the contract is versioned\nagainst, and they carry `request_id` on every failure.\n\n### Pagination\n\nEvery v1 list takes `limit` (1–100, default 20) and `after` (an opaque cursor\nfrom the previous response's `next_cursor`). Page manually, or let the SDK do it —\neach list has a companion `*All` async generator that walks the pages and yields\nindividual items:\n\n```ts\n// Manual: stop when has_more goes false.\nlet page = await sendly.campaigns.list({ limit: 50 });\nwhile (page.has_more && page.next_cursor) {\n  page = await sendly.campaigns.list({ limit: 50, after: page.next_cursor });\n}\n\n// Automatic:\nfor await (const campaign of sendly.campaigns.listAll({ limit: 50 })) {\n  console.log(campaign.id, campaign.status);\n}\n```\n\nThe seventeen companions: `campaigns.listAll`, `campaigns.listFailuresAll`,\n`contacts.listAllV1`, `deliverability.listDmarcReportsAll`,\n`deliverability.listDomainStatsAll`, `domains.listAllV1`, `events.listAll`,\n`lists.listAllV1`, `segments.listAll`, `segments.listContactsAll`,\n`suppression.listAllV1`, `templates.listAllV1`, `topics.listAll`,\n`validation.listResultsAll`, `webhooks.listAllV1`, `workflows.listAll` and\n`workflows.listExecutionsAll`.\n\nThrough 1.0 there were two dialects: `topics.list` and\n`validation.listResults` took `cursor` and answered `cursor` where every other\nv1 list took `after`. The platform collapsed that for 1.1, so there is one shape\nto learn and one to write. If you were driving either of those two by hand, pass\n`after` and read `next_cursor`.\n\nKeep the filter and sort arguments **fixed for the whole walk** — the cursor\nencodes them, and changing them mid-pagination is answered with\n`422 validation_error` telling you to restart from the first page. There is\ndeliberately no total count. The one exception is `campaigns.listFailures`, which\nalso carries `total`, because `retryFailed` acts on that number and `has_more`\nalone cannot tell you whether 3 or 30,000 sends failed.\n\n### Campaigns: who did not get it, and re-driving them\n\n`stats` says how many sends failed; only `listFailures` says who.\n\n```ts\nconst failures = await sendly.campaigns.listFailures(campaign.id, { limit: 100 });\nconsole.log(failures.total, \"recipients did not receive it\");\n\nfor await (const failure of sendly.campaigns.listFailuresAll(campaign.id)) {\n  console.log(failure.email, failure.reason, failure.failed_at);\n}\n\nconst retry = await sendly.campaigns.retryFailed(campaign.id);\nconsole.log(\"re-queued\", retry.queued);\n```\n\n`reason` comes from a fixed vocabulary rather than the underlying error text, so\nit is stable enough to branch on; it is `null` on rows recorded before reasons\nwere captured.\n\n`retryFailed` re-drives **only** the recipients whose send failed — nobody who\nalready received the campaign is mailed a second time, because each ledger row is\nclaimed before it is touched and a row whose email exists already is re-queued\nrather than re-sent. The walk runs in the background, so the call resolves as\nsoon as it is queued, reporting how many failed rows it was started for. Only a\n`SENT` campaign qualifies; a retry already running answers `409 conflict`.\n\n### Workflows: the graph, and the lifecycle\n\n`getGraph` returns every step — including the `TRIGGER` entry node — plus the\ndirected transitions between them, and that body is accepted verbatim by\n`replaceGraph`:\n\n```ts\nconst graph = await sendly.workflows.getGraph(workflow.id);\ngraph.steps[0].config; // stored exactly as authored, camelCase keys and all\n\nconst updated = await sendly.workflows.replaceGraph(workflow.id, {\n  steps: graph.steps,\n  transitions: graph.transitions,\n});\n```\n\n`replaceGraph` is a **PUT**, and that is the point: a graph is nodes _plus_ the\nedges between them, so a partial edit to a step list has no meaning without the\ntransitions that reference it — half-applied, it would leave steps pointing at\nsteps that no longer exist. Ids decide the outcome per step: one you send is\nupdated in place, a fresh uuid creates a step, and an id you omit deletes that\nstep _and its run history_. Exactly one step must be a `TRIGGER`, every\ntransition must name steps in the same document, and no step may point at itself.\nIt is refused with `409 conflict` while the workflow has running executions —\nthose runs are standing on the steps being replaced.\n\n`clone` copies a workflow and its whole graph. The copy is **always created\ndisabled**, whatever the original was: a clone exists to be reviewed, and one\nthat started live would match the same trigger events as its original from the\nmoment it appeared.\n\n```ts\nconst copy = await sendly.workflows.clone(workflow.id, { name: \"Welcome (v2 test)\" });\n```\n\n`pause` and `resume` are deliberately asymmetric:\n\n```ts\nconst paused = await sendly.workflows.pause(workflow.id);\nconsole.log(\"cancelled\", paused.cancelled_executions, \"in-flight runs\");\n\nconst resumed = await sendly.workflows.resume(workflow.id);\nconsole.log(resumed.cancelled_executions); // always 0\n```\n\n**Pausing cancels every `RUNNING`/`WAITING` execution** and reports how many —\nthat is what separates it from `update(id, { enabled: false })`, which only stops\nnew runs starting and leaves every in-flight contact walking the graph, next\ndelay still expiring, next email still sending. **Resuming re-opens the workflow\nto new runs and does not restore the cancelled ones.** The cancellation is\nterminal; there is no undo, so pause when you mean to stop the sends already in\nflight and disable when you only mean to close the door. `resume` is refused with\n`422 validation_error` while any step is still unconfigured.\n\n### Events: `track` vs `record`\n\n`events.track` is the legacy `POST /api/track` endpoint and is unchanged — its\npayload field is still `data`. `events.record` is the same capability on\n`/api/v1/events`, named differently only because `track` was taken, and its\npayload field is `payload`. New integrations should prefer `record`, which also\nunlocks `events.list`, `events.listNames`, and `events.stats`.\n\n### Emails: `send` vs `sendLegacy`\n\nThe same split, resolved the other way round: since 1.0, `emails.send` IS the\nversioned send. It posts to `/api/v1/emails` and answers `202` with\n`{ id, status, to, from }`, where `status` is a real delivery state you can poll\non. It takes one recipient — use `cc`/`bcc` to copy others — instead of fanning\nan array out. `emails.sendLegacy` is the pre-1.0 send on `POST /api/emails`,\nunchanged: row ids, **no delivery status**, array `to` fanned out. See\n[Upgrading from 0.x](#upgrading-from-0x).\n\n`send` accepts an `idempotencyKey`; `sendTest` deliberately does not (see\n[Idempotency](#idempotency)).\n\n```ts\nconst receipt = await sendly.emails.send(\n  { to: \"user@example.com\", subject: \"Order confirmed\", body: \"<p>Thanks.</p>\" },\n  { idempotencyKey: `order-${orderId}` },\n);\nconsole.log(receipt.id, receipt.status); // status is a real delivery state\n```\n\n### Test sends\n\n`emails.sendTest` proves the send path works without touching a live\nrecipient. Two things about it are easy to get backwards:\n\n- **The sandbox address is the _sender_, not the destination.** It is resolved\n  server-side, and naming a `from` yourself is **refused** rather than ignored —\n  so a request expecting a different sender never gets a success it would\n  misread. `projects.get().sandbox_address` tells you what it sends _from_; the\n  response's `from` says the same thing.\n- **It lands in the project owner's own inbox.** `to` is optional and defaults\n  to the project owner's verified account email, which is the only address a\n  sandbox send may reach — any other value is refused.\n\n```ts\nconst test = await sendly.emails.sendTest({ subject: \"hi\", body: \"<p>hi</p>\" });\nconsole.log(test.to, test.from, test.sandbox); // sandbox is always true here\n```\n\nEverything else applies unchanged: the same rendering, the same content scan,\nthe same daily and trust-tier caps as a real send.\n\n### The current project\n\n```ts\nconst project = await sendly.projects.get();\nconsole.log(project.name, project.sandbox_address, project.ses_region);\n```\n\nTakes no id — the project is whichever one the API key belongs to. There is no\n`create` here; see below.\n\n### What the SDK deliberately does not expose\n\nAn API key resolves no user, and a handful of routes resolve the acting project\nadmin from the session before reading any scope — so they answer `401` to any\nkey, however broad its scopes. The contract states this: those operations\npublish `SessionAuth` without `ApiKeyAuth`.\n\nRather than ship methods that could never succeed, they are listed in the\ncontract suite's `NOT_SDK_CALLABLE` and checked against the spec's own\ndeclarations, in both directions. They are: creating and deleting a mailbox,\ncreating and revoking an app password, all four API-key operations, and\ncreating a project. Use the dashboard or an OAuth connection for those.\n\nMailbox **lifecycle** is what stays out of reach — not the mailbox resource as a\nwhole. The three reads (`mailboxes.list`, `mailboxes.get`,\n`mailboxes.listAppPasswords`) have a conditional membership check, and\n`mailboxes.sendMessage` / `mailboxes.draftMessage` publish `ApiKeyAuth` outright,\nso a key really can call all five.\n\n## Error handling\n\nEvery non-2xx response throws a typed `SendlyError` subclass. Switch on the\nclass (no string matching needed):\n\n```ts\nimport {\n  SendlyValidationError,\n  SendlyAuthenticationError,\n  SendlyNotFoundError,\n  SendlyRateLimitError,\n  SendlyServerError,\n} from \"sendly-sdk\";\n\ntry {\n  await sendly.emails.send({ from, to, subject, body });\n} catch (err) {\n  if (err instanceof SendlyValidationError) {\n    console.warn(\"bad input:\", err.errorCode, err.message);\n  } else if (err instanceof SendlyAuthenticationError) {\n    console.error(\"check your API key\");\n  } else if (err instanceof SendlyRateLimitError) {\n    // back off and retry\n  } else if (err instanceof SendlyServerError) {\n    // 5xx — retry with exponential backoff\n  } else {\n    throw err;\n  }\n}\n```\n\nEach error exposes:\n\n- `statusCode` — HTTP status (0 for transport failures)\n- `errorCode` — stable machine code from the API envelope\n- `message` — human-readable message\n- `body` — full parsed response body for debugging\n- `requestId` — correlation id, on `/api/v1` errors only (see below)\n- `fieldErrors` — per-field failures, on `/api/v1` `422` responses only\n\n### `/api/v1` errors (RFC 9457)\n\nThe versioned surface answers failures with a `application/problem+json`\ndocument: `{ type, title, status, detail?, instance?, code, request_id?,\nerrors? }`. The SDK maps it onto the **same** error subclasses by HTTP status,\nso nothing about `instanceof` handling changes. What it adds is better detail:\n\n- `errorCode` is the problem's stable lowercase registry value —\n  `invalid_api_key`, `invalid_session`, `scope_missing`, `project_access_denied`,\n  `project_disabled`, `validation_error`, `resource_not_found`, `conflict`,\n  `rate_limited`, `quota_exhausted`, `idempotency_key_reused`, `enqueue_failed`,\n  `internal_error`.\n- `message` is the problem's `detail` (falling back to `title`).\n- `requestId` is the `request_id` — quote it in support requests.\n- `fieldErrors` is the `errors` array on a `422 validation_error`: one\n  `{ pointer, code, message }` per offending field, `pointer` being an RFC 6901\n  JSON Pointer.\n\n```ts\ntry {\n  await sendly.campaigns.create({ name: \"\", subject: \"Hi\", body, from, audience_type: \"ALL\" });\n} catch (err) {\n  if (err instanceof SendlyValidationError) {\n    for (const field of err.fieldErrors ?? []) {\n      console.warn(`${field.pointer}: ${field.message}`);\n    }\n    console.warn(\"request id:\", err.requestId);\n  }\n}\n```\n\nTwo different situations share HTTP `429`, and `errorCode` is what separates\nthem: `rate_limited` is the per-key burst limiter and clears on its own, while\n`quota_exhausted` is your billing-period quota and stays until the period resets\nor the plan is upgraded — retrying it will not help. When reading the reset\nhint, note that `X-RateLimit-Reset` is an **absolute** epoch-seconds instant\nwhereas the draft-11 `RateLimit` header's `t=` is **delta** seconds. The SDK\ndoes not retry on your behalf.\n\nNote that `404 resource_not_found` is an ordinary answer from\n`suppression.getV1`, not a failure: it is how that route says \"this address is\nnot suppressed\". Catch it rather than logging it.\n\n### Legacy `/api/*` errors\n\nInvalid input is reported as `SendlyValidationError`. The API returns **422**\n(`errorCode: \"VALIDATION_ERROR\"`) for schema validation failures; the SDK maps\nboth `400` and `422` to `SendlyValidationError`, so existing `instanceof`\nchecks keep working. Field-level detail, when present, is on\n`err.body.error.details.errors`:\n\n```ts\nif (err instanceof SendlyValidationError) {\n  const fields = (err.body as { error?: { details?: { errors?: unknown[] } } })?.error?.details?.errors;\n  console.warn(\"validation failed:\", err.errorCode, fields);\n}\n```\n\nThe error envelope is `{ success: false, error: { message, code, details? } }`.\nContact bulk operations that previously failed with a `NO_PROJECT` code now\nsurface as `VALIDATION_ERROR`.\n\n## Idempotency\n\nPass `idempotencyKey` on any write that supports it — `emails.send`,\n`emails.sendLegacy`, `emails.batch`, `contacts.create`, `contacts.upsert`,\n`contacts.bulkCreate`, `campaigns.create` and `campaigns.send` — to make\nretries safe. Replays within 24 hours return the original result instead of\nacting twice.\n\nNothing added in 1.1 takes a key. The v1 creates (`contacts.createV1`,\n`lists.createV1`, `templates.createV1`, `domains.createV1`,\n`webhooks.createV1`, `suppression.createV1`, `topics.create`,\n`snippets.create`) are all either naturally idempotent on their own key or cheap\nto repeat, and `campaigns.retryFailed` is guarded by a `409` on a retry already\nrunning rather than by a replay ledger.\n\nTwo v1 writes deliberately take no key for reasons of their own. `events.record`\nis append-only and high-volume. `emails.sendTest` reaches only the caller's own\ninbox, a daily cap already bounds it, and \"send me another one\" is the normal\nsecond call rather than a mistake worth deduplicating.\n\n```ts\nawait sendly.emails.send({ from, to, subject, body }, { idempotencyKey: `signup-${userId}` });\n```\n\n## Custom fetch\n\nInject your own `fetch` for SSR, instrumentation, or testing:\n\n```ts\nconst sendly = new Sendly({\n  apiKey: \"sk_test\",\n  fetch: async (input, init) => {\n    console.log(\"outbound\", init?.method, input);\n    return globalThis.fetch(input, init);\n  },\n});\n```\n\n## API reference\n\nFull reference, schemas, and live OpenAPI spec live at\n[https://docs.sendly.now](https://docs.sendly.now).\n\n## Development\n\n```bash\npnpm install        # install pinned toolchain\npnpm test           # run the vitest suite once\npnpm lint           # eslint (0 warnings tolerated)\npnpm check-types    # tsc --noEmit\npnpm build          # regenerate types from openapi.json, then bundle with tsup\n```\n\nThe type definitions in `src/types.generated.ts` are generated from\n`openapi.json` via `pnpm build:types`. `openapi.json` is a committed snapshot of\nSendly's OpenAPI contract, and the SDK surface is verified against it by the\ncontract suite in `src/__tests__/contract.test.ts`.\n\n### Refreshing `openapi.json`\n\n`pnpm sync-spec` requires `SENDLY_OPENAPI_URL`. There is **no default**, and in\nparticular it does not default to production:\n\n```bash\nSENDLY_OPENAPI_URL=/path/to/sendly/apps/web/openapi/openapi.json pnpm sync-spec\npnpm build:types   # regenerate types (pnpm build runs this for you)\n```\n\n`SENDLY_OPENAPI_URL` accepts a filesystem path (the normal case — the committed\ncontract in the Sendly platform monorepo at `apps/web/openapi/openapi.json`) or\nan `http(s)://` URL of a local or staging API. Running `pnpm sync-spec` with it\nunset exits non-zero and prints what to set.\n\n**Do not point it at `https://api.sendly.now`.** Vendoring the spec from the\ndeployed API makes the SDK mirror what is _running_ rather than what the repo\n_declares_, so any drift between the platform's code and its committed contract\nis laundered into \"correct\" on the way in — the SDK regenerates to match the\ndeployment and the mismatch vanishes silently. That destroys the vendored spec's\nonly job: it is the fixed reference the contract suite compares against, so an\nSDK synced from production can no longer detect the very drift it exists to\ncatch. It is also unreproducible and unreviewable.\n\nThis is not hard-blocked — \"what does production actually serve?\" is a legitimate\none-off. Doing it prints an unmissable warning (and a CI annotation), because\n_quiet_ is what made the old default dangerous, not the host. Never commit the\nresult, and never wire that host into CI or any unattended job.\n\n`pnpm check-spec-drift` compares the committed `openapi.json` to the same source\nand never fails the build. With `SENDLY_OPENAPI_URL` unset it skips with a notice\nrather than erroring, so CI and fork pull requests stay green.\n\n## License\n\n[MIT](./LICENSE) © Devino Solutions\n","readmeFilename":"README.md"}