TypeScript SDK
Install @workwae/sdk, call the API with typed methods, and use its pagination, error and webhook helpers.
@workwae/sdk is a typed client for every /v1 operation, built from the same contract as the API. It also ships helpers for cursors, errors and webhook signatures.
Install
The SDK runs on Node.js 20 or later, Bun, Deno, Cloudflare Workers and browsers.
bun add @workwae/sdkMake a call
import { createClient } from "@workwae/sdk";
const workwae = createClient({ apiKey: process.env.WORKWAE_API_KEY });
const account = await workwae.accounts.get({ accountId });Every answer is validated against the contract, so date-time fields arrive as Dates. Every contract type (Account, Message, Event, ...) is exported from @workwae/sdk, along with EVENT_TYPES and the template length limits (MAX_TEMPLATE_NAME_LENGTH and the other MAX_TEMPLATE_* constants).
Calling conventions
Methods are named after the operation ID in the API reference: accounts.get is workwae.accounts.get, outbox.create is workwae.outbox.create.
Each method takes one object holding the path, query and body fields together:
await workwae.messages.list({ accountId, mailboxId, unread: true, limit: 50 });Four DELETEs that take query options keep the path and the query apart as { params, query }, so an option is not mixed into the path: accounts.delete, messages.delete, calendarEvents.delete and suppressions.delete.
await workwae.accounts.delete({ params: { accountId }, query: { purge: true } });Client options
createClient takes these options:
| Option | Meaning |
|---|---|
apiKey | An API key (wk_...) or an account token (wat_...), or a function that returns one per request. Use a function when the token is refreshed. |
baseUrl | API origin. Defaults to https://api.workwae.com. Set it only to route traffic through your own proxy or gateway. Paths are appended, so a path prefix is kept. |
fetch | Replaces the global fetch. It is called as (request, init) => Promise<Response>, so a plain function is enough. |
headers | Extra headers sent on every request. The client's own authorization and idempotency-key headers take precedence. |
retry | maxRetries, maxDelayMs and wait. See Retries. |
import { createClient } from "@workwae/sdk";
const workwae = createClient({
apiKey: () => fetch("/api/workwae-token").then((response) => response.text()),
headers: { "x-application": "billing" },
});Per-call options
A method's second argument is optional:
| Option | Meaning |
|---|---|
signal | Aborts the request and any retry wait. |
context.idempotencyKey | The Idempotency-Key of a POST. Without one, the client generates a key per call and reuses it on its own retries. Pass your own so that a retry from another process, or after a timeout, gets the first attempt's answer. |
await workwae.outbox.create(
{ accountId, to: [{ address: "ada@example.com" }], subject: "Invoice 1042", text: "..." },
{ signal: controller.signal, context: { idempotencyKey: "invoice-1042" } },
);How the API treats keys is in Idempotency.
Retries
The client retries an answer of 429 or 503, which the API sends before acting on a request, so a retry cannot apply it twice. Every attempt of a POST carries the same Idempotency-Key.
- The wait is the answer's
Retry-Afterwhen it has one. Otherwise it is an exponential backoff from 500 ms, with jitter. - If
Retry-Afteris longer thanmaxDelayMs, the client does not wait: it throws the error. - Any other status, or the last attempt's answer, is returned or thrown as is.
retry option | Default | Meaning |
|---|---|---|
maxRetries | 2 | Attempts after the first. |
maxDelayMs | 60000 | Longest single wait, in milliseconds. |
wait | a timer | (ms, signal) => Promise<void>. Replaces the timer; it should reject when signal aborts. |
Rate limits lists the limits behind 429.
Pagination helpers
paginate, pages and changes take the list method itself, so the item type and the filters stay typed. Each accepts { signal } as a third argument.
| Helper | Does |
|---|---|
paginate(list, input) | Yields every item, following nextCursor until a page whose cursor is null. |
pages(list, input) | Yields each page, so you can store nextCursor between pages. |
changes(list, input) | Reads the change feed until an empty page (caught up). feed.cursor is the resume point. |
import { changes, pages, paginate } from "@workwae/sdk";
for await (const message of paginate(workwae.messages.list, { accountId, unread: true })) {
console.log(message.subject);
}
for await (const page of pages(workwae.accounts.list, { limit: 200 })) {
console.log(page.items.length, page.nextCursor);
}
const feed = changes(workwae.changes.list, { cursor: saved });
for await (const event of feed) await handle(event);
saved = feed.cursor;Use changes for the change feed
paginate and pages never end on GET /v1/changes: its nextCursor is never null. Use changes, which stops at the first empty page.
feed.cursor moves past a page only after its last event was consumed. Saving it after processing gives at-least-once delivery: stopping mid-page replays that page next time. See Pagination and Change feed.
Errors
A refusal throws an ORPCError:
| Field | Meaning |
|---|---|
code | The stable reason, such as account_not_found. |
status | The HTTP status. |
message | Text for people. |
data | The reason's details, for reasons that carry any. |
requestId | The answer's X-Request-Id, for support. Undefined when the answer had none. |
A failure whose body cannot be decoded, such as a proxy's error page, still throws an ORPCError keyed by its status.
Two ways to narrow an error:
safe(call)returns[error, data]without throwing.isDefinedError(error)then narrowserror.codeto the reasons that operation declares.isApiError(error, reason?)is true for any API error, or only forreason, and narrowsdatato that reason's shape.
import { isApiError, isDefinedError, safe } from "@workwae/sdk";
const [error, account] = await safe(workwae.accounts.get({ accountId }));
if (isDefinedError(error)) {
// error.code is one of the reasons accounts.get declares.
if (error.code === "account_not_found") {
// The account does not exist or was deleted.
}
} else if (error) throw error;
try {
await workwae.outbox.create({ accountId, to: [], text: "hi" });
} catch (error) {
if (isApiError(error, "invalid_input")) console.log(error.data.issues);
else if (isApiError(error)) console.log(error.code, error.requestId);
else throw error;
}Every reason and its status is in Errors.
Webhooks
parseWebhook(secret, headers, body) checks the Standard Webhooks signature and returns the event with its dates as Dates. Pass the raw request body exactly as received. headers is a Headers object or a plain record such as Node's req.headers.
- A type this SDK knows has
known: trueand narrows ontype. - A type added after this SDK version has
known: false, and itsdatais passed on unchecked. - A failed check throws
WebhookVerificationError. Itsfailureismissing_headers,timestamp,signatureorpayload(signed, but not a valid event).
import { parseWebhook, WebhookVerificationError } from "@workwae/sdk";
const body = await request.text();
try {
const event = await parseWebhook(secret, request.headers, body);
if (event.known && event.type === "export.completed") {
console.log(event.data.exportId, event.data.sha256);
}
} catch (error) {
if (error instanceof WebhookVerificationError) {
// Reject the delivery.
} else throw error;
}To check only the signature, verifyWebhook returns a boolean and checkWebhookSignature returns the failure, or null when the signature is valid. All three take an options argument with toleranceSeconds (default WEBHOOK_TOLERANCE_SECONDS, 300). See Webhook signatures.