SUQO TypeScript SDK — usage
@suqo/sdk, server-side only — it holds a full-access API key and must never
be bundled into browser code. Node ≥18, zero runtime dependencies (fetch
and node:crypto are both built in), strict TypeScript, dual ESM/CJS build.
Not yet published. npm view @suqo/sdk 404s against the real npm
registry today (package.json is 0.0.1; specs/versioning.md's 1.0.0
row is "Pending — this ticket's release," not shipped). Don't write
npm install @suqo/sdk as a working command — see references/client-setup.md
for what actually works right now (installing from a local build/tarball).
Workflow
- Name the task — checkout flow, subscription dashboard, webhook
endpoint, cancellation route, sync job.
- Load only the reference you need from
references/. Never load them
all. references/api-surface.md is the authority on signatures; load it
before writing any SDK call.
- Start from a template in
templates/ when one is close, rather than
writing from scratch.
- Write against documented methods only. If a reference doesn't cover
something, say so — do not invent a method, parameter, or property. In
particular: there is no
subscriptions.retrieve(id) at all;
RateLimitError exists but the live API has never thrown one;
rate-limiting and idempotency are both explicitly "planned," not
implemented, in the SDK's own docs.
- Verify —
tsc --noEmit against the real SDK types, then the
project's own test/lint command. If a sandbox key is available, exercise
the code for real; otherwise say plainly that it was only typechecked,
not run against the API.
The whole callable surface
| Call |
Returns |
new SuqoClient({ apiKey, baseUrl?, timeout?, maxRetries?, dispatcher? }) |
SuqoClient |
suqo.products.list(params?) |
Page<Product> |
suqo.products.autoPaging(params?) |
AsyncIterableIterator<Product> |
suqo.subscriptions.list(params?) |
SubscriptionPage<Subscription> |
suqo.subscriptions.autoPaging(params?) |
AsyncIterableIterator<Subscription> |
suqo.subscriptions.create(params) |
CreateSubscriptionResponse |
suqo.subscriptions.cancel(id) |
MessageResponse |
suqo.subscriptions.updateBillingCycle(params) |
MessageResponse |
suqo.subscriptions.resume(id) |
MessageResponse |
suqo.customers.list(params?) / .autoPaging() / .retrieve(id: number) |
Page<Customer> / iterator / Customer |
suqo.webhooks.verify(options) |
boolean (never throws) |
mapHttpError(input) |
SuqoError |
There is no subscriptions.retrieve(). Everything above is the complete
surface — references/api-surface.md lists exactly what's deliberately
not exported too (internal client/auth stubs, the HTTP layer, every
serialize*/deserialize* helper).
Rules that trip people up
Apply these without being asked — most bugs against this SDK are one of them.
- Decimal fields are strings.
price, vatPercentage, totalSubscribers,
every amount. Never coerce to number.
customer on the SDK is client on the wire — renamed because
client collides with the SDK's own client object. Only the root key is
renamed; nested billing.* fields get a billing_ wire prefix on write,
shipping.* fields don't, and neither is prefixed on read. See
references/subscriptions.md — this is the single easiest field-mapping
mistake in the SDK.
- Trailing slash is mandatory on every route the SDK calls — the SDK
handles this for you, but don't hand-build a URL to bypass it.
- Writes never auto-retry.
create, cancel, updateBillingCycle,
resume — none of them retry, ever, regardless of maxRetries. No
idempotency-key support exists yet. A NetworkError from a write means it
may or may not have landed; reconcile with list(), don't resend blindly.
- Two different 400 shapes exist and
ValidationError normalizes both —
field-keyed (fieldErrors) and detail-shaped (message only). See
references/errors.md.
customers is real here, unlike its stub-only counterpart in other SUQO
SDKs' specs. The SDK repo's own specs/SDK-SPEC.md/typescript-addendum.md
are stale on this point — source, tests, and docs/user/customers.md all
confirm it's a fully working resource. See references/customers.md.
Webhooks
suqo.webhooks.verify() needs no network call and the API key plays no role
in verification — but it's still a method on an already-constructed
SuqoClient (there's no standalone export for it). Never throws; every
failure mode returns false.
const secret = process.env.SUQO_WEBHOOK_SECRET;
// verify() itself handles a missing secret fine (returns false, doesn't throw) — check it
// explicitly anyway so a misconfigured deployment gets a distinct, loud signal instead of
// blending into ordinary bad-signature noise.
if (!secret) throw new Error("SUQO_WEBHOOK_SECRET is not set");
const signature = req.header("x-suqo-signature");
const timestamp = req.header("x-suqo-timestamp");
if (!signature || !timestamp) throw new Error("Missing signature/timestamp header"); // 400 and stop, in real code
const verified = suqo.webhooks.verify({
rawBody, // the exact bytes received — see below
signature,
timestamp,
secret,
toleranceSec: 300, // optional, default 300
});
Pass the raw bytes
A body re-serialized from a parse verifies only by luck. Per framework:
// Express: express.raw() scoped to just this route, never mounted globally
router.post("/webhooks/suqo", express.raw({ type: "application/json" }), handler);
// Next.js App Router
const rawBody = await req.text();
// Fastify: no drop-in equivalent — override addContentTypeParser() inside
// this route's own plugin scope (fastify.register(...)), not on the root
// instance, or every other route loses JSON parsing too.
// Plain Node — buffer the chunks yourself before parsing anything
Handler checklist
- Read the raw body first, before any parsing.
- Verify, and on
false return 400 and stop. Don't parse, don't process.
- Parse only after verifying — in its own try/catch. A verified signature
proves the bytes came from SUQO, not that they're valid JSON; on plain
http.ServerResponse this genuinely crashes the process if the 2xx
already went out and the parse failure reaches a handler that tries to
send a second response (confirmed, not hypothetical — see
references/webhooks.md).
- Return
2xx fast, then process out of band — a slow handler gets
retried and duplicated. On a serverless/edge runtime (Vercel, Lambda,
...) that pattern is unsafe: the function can be frozen the instant the
response is sent, silently dropping unawaited work — await the
processing there instead, or use the platform's own keep-alive
(after(), waitUntil). See templates/webhook-nextjs-route.ts.
- Be idempotent, keyed on
subscription_id (checkout/status events) or
api_key_id (api_key.* events) — no event has a field literally named
id. Redelivery is normal; there's no replay store.
- Never echo the body back, and never treat a field inside it as an
authorization decision on its own — verification confirms it came from
SUQO, not that its contents are safe to act on blindly.
Exact signed-payload format, the event type catalogue (stays snake_case on
purpose), and the dashboard's test-event quirk are in
references/webhooks.md.
Reference index
| Reference |
Load when |
references/api-surface.md |
Writing any SDK call — exact signatures, exports, and what's deliberately not public. Load first. |
references/client-setup.md |
Constructing the client, environment inference, the not-yet-published install story, framework wiring. |
references/products.md |
Listing products/plans, the pbpId chain into subscriptions. |
references/subscriptions.md |
Create, cancel, billing-cycle, resume flows; the customer/client wire rename and its billing-prefix asymmetry. |
references/customers.md |
The real (not stub) customers resource; the integer id exception. |
references/webhooks.md |
Verification semantics, signed-payload format, event catalogue. |
references/errors.md |
Error hierarchy, retry rules, mapping to HTTP responses. |
references/models.md |
Property tables for every model, and the short list of wire↔SDK renames. |
references/pagination.md |
Manual page/pageSize vs .autoPaging(), and why an in-flight call can't be cancelled today. |
Templates index
| Template |
Use for |
templates/suqo-client.ts |
Lazy, guarded singleton client factory. |
templates/list-products.ts |
autoPaging read down to a billing period's pbpId. |
templates/list-products-paged.ts |
Manual page/pageSize for a Next/Previous UI control. |
templates/list-customers.ts |
list/retrieve on the real (not stub) customers resource. |
templates/subscription-error-handling.ts |
The shared write error ladder used by both templates below. |
templates/create-subscription.ts |
Full nested create(). |
templates/manage-subscription.ts |
cancel/updateBillingCycle/resume. |
templates/webhook-plain-node.ts |
No-framework webhook endpoint. |
templates/webhook-express.ts |
Express route with express.raw() scoped correctly. |
templates/webhook-fastify.ts |
Fastify route with a plugin-scoped raw-body parser. |
templates/webhook-nextjs-route.ts |
Next.js App Router route handler. |
templates/subscription-write.test.ts |
Vitest test for a write, stubbing fetch directly. |
Testing SDK code
Stub the global fetch, the same way the SDK's own test suite does
(test/http/HttpClient.test.ts) — no extra dependency needed:
import { vi } from "vitest";
vi.stubGlobal("fetch", vi.fn());
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ /* wire-shaped body */ }), { status: 201 }),
);
Every layer above the network — the auth header, retry/backoff, error
mapping, pagination — still runs for real; only the raw Response is faked.
msw is the route/URL-level alternative the SDK's own test/contract/
suite uses.
Don't reach for SuqoClientOptions.dispatcher for this, even though
it's a real, documented option forwarded into every fetch() call. Passing
a MockAgent/MockPool from a separately npm-installed undici package as
that per-call dispatcher is not reliably recognized by Node's built-in
global fetch — confirmed here to silently fall through to a real network
attempt (and hang) rather than intercept, since the two are different
module realms. undici's own setGlobalDispatcher(mockAgent) (not the
per-call option) is cross-realm-safe if you want that layer specifically,
but vi.stubGlobal is simpler and needs nothing extra installed.
Notes for maintainers
Update the Reference index and Templates index tables above whenever a
file is added to references/ or templates/ — this skill's own history
has already drifted out of sync with itself more than once from a table
edit getting missed.
Never mock SuqoClient or a resource directly — mocking the thing under
test tests the mock instead of the integration, the same rule PHP's skill
holds to for HttpClientInterface.
1---2name: ts-sdk-usage3description: Use for any TypeScript/Node work with the SUQO TS SDK (@suqo/sdk) — listing products, customers, or subscriptions, creating or cancelling subscriptions, resuming or moving a billing cycle, paging, verifying inbound webhooks, wiring the client into Express, Fastify, Next.js, or plain Node, reading SUQO error responses, or testing code that calls the SDK. Provides exact method signatures so the SDK is never guessed at, and the raw-body rule that most broken webhook handlers get wrong.4---56# SUQO TypeScript SDK — usage78`@suqo/sdk`, server-side only — it holds a full-access API key and must never9be bundled into browser code. Node ≥18, zero runtime dependencies (`fetch`10and `node:crypto` are both built in), strict TypeScript, dual ESM/CJS build.1112**Not yet published.** `npm view @suqo/sdk` 404s against the real npm13registry today (`package.json` is `0.0.1`; `specs/versioning.md`'s `1.0.0`14row is "Pending — this ticket's release," not shipped). Don't write15`npm install @suqo/sdk` as a working command — see `references/client-setup.md`16for what actually works right now (installing from a local build/tarball).1718## Workflow19201. **Name the task** — checkout flow, subscription dashboard, webhook21 endpoint, cancellation route, sync job.222. **Load only the reference you need** from `references/`. Never load them23 all. `references/api-surface.md` is the authority on signatures; load it24 before writing any SDK call.253. **Start from a template** in `templates/` when one is close, rather than26 writing from scratch.274. **Write against documented methods only.** If a reference doesn't cover28 something, say so — do not invent a method, parameter, or property. In29 particular: there is no `subscriptions.retrieve(id)` at all;30 `RateLimitError` exists but the live API has never thrown one;31 rate-limiting and idempotency are both explicitly "planned," not32 implemented, in the SDK's own docs.335. **Verify** — `tsc --noEmit` against the real SDK types, then the34 project's own test/lint command. If a sandbox key is available, exercise35 the code for real; otherwise say plainly that it was only typechecked,36 not run against the API.3738## The whole callable surface3940| Call | Returns |41| --- | --- |42| `new SuqoClient({ apiKey, baseUrl?, timeout?, maxRetries?, dispatcher? })` | `SuqoClient` |43| `suqo.products.list(params?)` | `Page<Product>` |44| `suqo.products.autoPaging(params?)` | `AsyncIterableIterator<Product>` |45| `suqo.subscriptions.list(params?)` | `SubscriptionPage<Subscription>` |46| `suqo.subscriptions.autoPaging(params?)` | `AsyncIterableIterator<Subscription>` |47| `suqo.subscriptions.create(params)` | `CreateSubscriptionResponse` |48| `suqo.subscriptions.cancel(id)` | `MessageResponse` |49| `suqo.subscriptions.updateBillingCycle(params)` | `MessageResponse` |50| `suqo.subscriptions.resume(id)` | `MessageResponse` |51| `suqo.customers.list(params?)` / `.autoPaging()` / `.retrieve(id: number)` | `Page<Customer>` / iterator / `Customer` |52| `suqo.webhooks.verify(options)` | `boolean` (never throws) |53| `mapHttpError(input)` | `SuqoError` |5455There is no `subscriptions.retrieve()`. Everything above is the complete56surface — `references/api-surface.md` lists exactly what's deliberately57*not* exported too (internal client/auth stubs, the HTTP layer, every58`serialize*`/`deserialize*` helper).5960## Rules that trip people up6162Apply these without being asked — most bugs against this SDK are one of them.6364- **Decimal fields are strings.** `price`, `vatPercentage`, `totalSubscribers`,65 every amount. Never coerce to `number`.66- **`customer` on the SDK is `client` on the wire** — renamed because67 `client` collides with the SDK's own client object. Only the root key is68 renamed; nested `billing.*` fields get a `billing_` wire prefix on write,69 `shipping.*` fields don't, and neither is prefixed on read. See70 `references/subscriptions.md` — this is the single easiest field-mapping71 mistake in the SDK.72- **Trailing slash is mandatory** on every route the SDK calls — the SDK73 handles this for you, but don't hand-build a URL to bypass it.74- **Writes never auto-retry.** `create`, `cancel`, `updateBillingCycle`,75 `resume` — none of them retry, ever, regardless of `maxRetries`. No76 idempotency-key support exists yet. A `NetworkError` from a write means it77 may or may not have landed; reconcile with `list()`, don't resend blindly.78- **Two different 400 shapes exist** and `ValidationError` normalizes both —79 field-keyed (`fieldErrors`) and `detail`-shaped (`message` only). See80 `references/errors.md`.81- **`customers` is real here, unlike its stub-only counterpart in other SUQO82 SDKs' specs.** The SDK repo's own `specs/SDK-SPEC.md`/`typescript-addendum.md`83 are stale on this point — source, tests, and `docs/user/customers.md` all84 confirm it's a fully working resource. See `references/customers.md`.8586## Webhooks8788`suqo.webhooks.verify()` needs no network call and the API key plays no role89in verification — but it's still a method on an already-constructed90`SuqoClient` (there's no standalone export for it). **Never throws**; every91failure mode returns `false`.9293```ts94const secret = process.env.SUQO_WEBHOOK_SECRET;95// verify() itself handles a missing secret fine (returns false, doesn't throw) — check it96// explicitly anyway so a misconfigured deployment gets a distinct, loud signal instead of97// blending into ordinary bad-signature noise.98if (!secret) throw new Error("SUQO_WEBHOOK_SECRET is not set");99100const signature = req.header("x-suqo-signature");101const timestamp = req.header("x-suqo-timestamp");102if (!signature || !timestamp) throw new Error("Missing signature/timestamp header"); // 400 and stop, in real code103104const verified = suqo.webhooks.verify({105 rawBody, // the exact bytes received — see below106 signature,107 timestamp,108 secret,109 toleranceSec: 300, // optional, default 300110});111```112113### Pass the raw bytes114115A body re-serialized from a parse verifies only by luck. Per framework:116117```ts118// Express: express.raw() scoped to just this route, never mounted globally119router.post("/webhooks/suqo", express.raw({ type: "application/json" }), handler);120121// Next.js App Router122const rawBody = await req.text();123124// Fastify: no drop-in equivalent — override addContentTypeParser() inside125// this route's own plugin scope (fastify.register(...)), not on the root126// instance, or every other route loses JSON parsing too.127128// Plain Node — buffer the chunks yourself before parsing anything129```130131### Handler checklist1321331. Read the raw body **first**, before any parsing.1342. Verify, and on `false` return `400` and stop. Don't parse, don't process.1353. Parse only after verifying — in its own try/catch. A verified signature136 proves the bytes came from SUQO, not that they're valid JSON; on plain137 `http.ServerResponse` this genuinely crashes the process if the 2xx138 already went out and the parse failure reaches a handler that tries to139 send a second response (confirmed, not hypothetical — see140 `references/webhooks.md`).1414. Return `2xx` fast, then process out of band — a slow handler gets142 retried and duplicated. **On a serverless/edge runtime** (Vercel, Lambda,143 ...) that pattern is unsafe: the function can be frozen the instant the144 response is sent, silently dropping unawaited work — `await` the145 processing there instead, or use the platform's own keep-alive146 (`after()`, `waitUntil`). See `templates/webhook-nextjs-route.ts`.1475. Be idempotent, keyed on `subscription_id` (checkout/status events) or148 `api_key_id` (`api_key.*` events) — no event has a field literally named149 `id`. Redelivery is normal; there's no replay store.1506. Never echo the body back, and never treat a field inside it as an151 authorization decision on its own — verification confirms it came from152 SUQO, not that its contents are safe to act on blindly.153154Exact signed-payload format, the event type catalogue (stays snake_case on155purpose), and the dashboard's test-event quirk are in156`references/webhooks.md`.157158## Reference index159160| Reference | Load when |161| --- | --- |162| `references/api-surface.md` | Writing any SDK call — exact signatures, exports, and what's deliberately not public. Load first. |163| `references/client-setup.md` | Constructing the client, environment inference, the not-yet-published install story, framework wiring. |164| `references/products.md` | Listing products/plans, the pbpId chain into subscriptions. |165| `references/subscriptions.md` | Create, cancel, billing-cycle, resume flows; the customer/client wire rename and its billing-prefix asymmetry. |166| `references/customers.md` | The real (not stub) customers resource; the integer id exception. |167| `references/webhooks.md` | Verification semantics, signed-payload format, event catalogue. |168| `references/errors.md` | Error hierarchy, retry rules, mapping to HTTP responses. |169| `references/models.md` | Property tables for every model, and the short list of wire↔SDK renames. |170| `references/pagination.md` | Manual `page`/`pageSize` vs `.autoPaging()`, and why an in-flight call can't be cancelled today. |171172## Templates index173174| Template | Use for |175| --- | --- |176| `templates/suqo-client.ts` | Lazy, guarded singleton client factory. |177| `templates/list-products.ts` | `autoPaging` read down to a billing period's `pbpId`. |178| `templates/list-products-paged.ts` | Manual `page`/`pageSize` for a Next/Previous UI control. |179| `templates/list-customers.ts` | `list`/`retrieve` on the real (not stub) customers resource. |180| `templates/subscription-error-handling.ts` | The shared write error ladder used by both templates below. |181| `templates/create-subscription.ts` | Full nested `create()`. |182| `templates/manage-subscription.ts` | `cancel`/`updateBillingCycle`/`resume`. |183| `templates/webhook-plain-node.ts` | No-framework webhook endpoint. |184| `templates/webhook-express.ts` | Express route with `express.raw()` scoped correctly. |185| `templates/webhook-fastify.ts` | Fastify route with a plugin-scoped raw-body parser. |186| `templates/webhook-nextjs-route.ts` | Next.js App Router route handler. |187| `templates/subscription-write.test.ts` | Vitest test for a write, stubbing `fetch` directly. |188189## Testing SDK code190191Stub the global `fetch`, the same way the SDK's own test suite does192(`test/http/HttpClient.test.ts`) — no extra dependency needed:193194```ts195import { vi } from "vitest";196197vi.stubGlobal("fetch", vi.fn());198vi.mocked(fetch).mockResolvedValueOnce(199 new Response(JSON.stringify({ /* wire-shaped body */ }), { status: 201 }),200);201```202203Every layer above the network — the auth header, retry/backoff, error204mapping, pagination — still runs for real; only the raw `Response` is faked.205`msw` is the route/URL-level alternative the SDK's own `test/contract/`206suite uses.207208**Don't reach for `SuqoClientOptions.dispatcher` for this**, even though209it's a real, documented option forwarded into every `fetch()` call. Passing210a `MockAgent`/`MockPool` from a separately npm-installed `undici` package as211that per-call `dispatcher` is not reliably recognized by Node's *built-in*212global `fetch` — confirmed here to silently fall through to a real network213attempt (and hang) rather than intercept, since the two are different214module realms. `undici`'s own `setGlobalDispatcher(mockAgent)` (not the215per-call option) is cross-realm-safe if you want that layer specifically,216but `vi.stubGlobal` is simpler and needs nothing extra installed.217218## Notes for maintainers219220Update the Reference index and Templates index tables above whenever a221file is added to `references/` or `templates/` — this skill's own history222has already drifted out of sync with itself more than once from a table223edit getting missed.224225Never mock `SuqoClient` or a resource directly — mocking the thing under226test tests the mock instead of the integration, the same rule PHP's skill227holds to for `HttpClientInterface`.