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/sdk

Make 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:

OptionMeaning
apiKeyAn API key (wk_...) or an account token (wat_...), or a function that returns one per request. Use a function when the token is refreshed.
baseUrlAPI 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.
fetchReplaces the global fetch. It is called as (request, init) => Promise<Response>, so a plain function is enough.
headersExtra headers sent on every request. The client's own authorization and idempotency-key headers take precedence.
retrymaxRetries, maxDelayMs and wait. See Retries.
A browser client that refreshes its account token
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:

OptionMeaning
signalAborts the request and any retry wait.
context.idempotencyKeyThe 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-After when it has one. Otherwise it is an exponential backoff from 500 ms, with jitter.
  • If Retry-After is longer than maxDelayMs, 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 optionDefaultMeaning
maxRetries2Attempts after the first.
maxDelayMs60000Longest single wait, in milliseconds.
waita 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.

HelperDoes
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:

FieldMeaning
codeThe stable reason, such as account_not_found.
statusThe HTTP status.
messageText for people.
dataThe reason's details, for reasons that carry any.
requestIdThe 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 narrows error.code to the reasons that operation declares.
  • isApiError(error, reason?) is true for any API error, or only for reason, and narrows data to 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: true and narrows on type.
  • A type added after this SDK version has known: false, and its data is passed on unchecked.
  • A failed check throws WebhookVerificationError. Its failure is missing_headers, timestamp, signature or payload (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.

On this page