---
title: "TypeScript SDK: @workwae/sdk · Workwae"
description: "@workwae/sdk is a typed TypeScript client for the Workwae API, with cursor, error and webhook signature helpers; it is an alpha, 0.1.0-alpha.1."
url: https://workwae.com/sdk
---

TypeScript SDK

# A typed client for every /v1 operation.

`@workwae/sdk` is typed from the same contract the API is built on: every method, input, answer and error. Dates arrive as `Date`, and cursors, errors and webhook signatures have helpers.

Alpha · 0.1.0-alpha.1

[Follow the quickstart](https://workwae.com/quickstart) [SDK guide](https://docs.workwae.com/guides/sdk) [API reference](https://docs.workwae.com/reference)

Install

## One package, any modern runtime.

Pick your package manager; the choice is remembered on every page that shows an install command.

- **Runtimes.** Node.js 20 or later, Bun, Deno, Cloudflare Workers and browsers. It needs `fetch` and Web Crypto, nothing else.
- **ESM only.** Its runtime dependencies are `zod` and `@orpc/*`.
- **Hosted API.** `baseUrl` defaults to `https://api.workwae.com`.

terminal

```text
npm install @workwae/sdk
```

Create a client

## Methods named after the operations.

`createClient` returns every `/v1` operation, grouped by resource: `workwae.accounts.get`, `workwae.outbox.create`, `workwae.calendarEvents.list`.

- Each method takes one object holding the path, query and body fields together.
- The four deletes that take options (`accounts`, `messages`, `calendarEvents` and `suppressions`) keep path and query apart as `{ params, query }`.
- A second argument takes `signal`, which aborts the call and any retry wait, and `context.idempotencyKey`.

workwae.ts

```ts
import { createClient } from "@workwae/sdk";

const workwae = createClient({
  apiKey: process.env.WORKWAE_API_KEY,
});

const page = await workwae.accounts.list({ limit: 50 });
console.log(page.items.length, page.nextCursor);

// One object holds the path, query and body fields
const message = await workwae.messages.get({ accountId, messageId });

// A DELETE that takes options keeps path and query apart
await workwae.accounts.delete({
  params: { accountId },
  query: { purge: true },
});

// A second argument carries per-call options
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
await workwae.accounts.list({}, { signal: controller.signal });
```

| Option | Meaning |
| --- | --- |
| `apiKey` | An API key (`wk_...`), an account token (`wat_...`), or a function returning one per request. |
| `baseUrl` | Defaults to `https://api.workwae.com`. Set it only to route through your own proxy or gateway. |
| `headers` | Extra headers sent with every request. |
| `fetch` | Replaces the global `fetch`: a proxy agent, instrumentation, tests. |
| `retry` | `{ maxRetries, maxDelayMs, wait }`; see Errors and retries. |

Authentication

## API keys on your server, account tokens in the browser.

- **API key, `wk_`.** Created in the console under API keys with the scopes the integration needs: `read`, `write`, `send` and `admin`, which do not imply each other. It reaches every account of the workspace unless you bind it to some, so keep it on your server.
- **Account token, `wat_`.** Mint it from your backend with a key that has the `write` scope. It acts on one account only, for 60 seconds to 24 hours (`ttlSeconds`, default 900).
- **What a token reaches.** That account, its mail, sending, calendar, contacts, identities and meeting connections. Any other operation answers 403 `account_token_forbidden`.

tokens.ts

```ts
// Server: a key with the write scope mints a token for one account
const { token, expiresAt } = await workwae.accounts.mintToken({
  accountId,
  ttlSeconds: 900,
});

// Browser: fetch a fresh token from your backend when one is needed
const client = createClient({
  apiKey: async () => {
    const response = await fetch("/api/workwae-token");
    return response.text();
  },
});
```

Mail

## Read the mailbox, queue a send.

`outbox.create` queues the message and answers at once with an outbox entry. Delivery arrives as `send.sent`, `send.retrying` and `send.failed` events carrying its id as `outboxId`.

- Every POST is sent with an `Idempotency-Key`, reused across the client’s own retries.
- Pass your own through `context.idempotencyKey` to make a call safe to repeat across restarts; the API keeps the first answer for 24 hours.

[Mail features](https://workwae.com/features/mail)

mail.ts

```ts
const { items } = await workwae.messages.list({
  accountId,
  unread: true,
  limit: 20,
});

const queued = await workwae.outbox.create({
  accountId,
  to: [{ name: "Ada Lovelace", address: "ada@example.com" }],
  subject: "Hello",
  text: "Sent with Workwae.",
  reference: "welcome_1042",
});
console.log(queued.id, queued.status, queued.undoUntil);

// Safe to repeat across restarts: the API replays the first answer
await workwae.outbox.create(
  {
    accountId,
    to: [{ address: "ada@example.com" }],
    subject: "Invoice 1042",
    text: "...",
  },
  { context: { idempotencyKey: "invoice-1042" } },
);
```

Calendar

## Events as typed values.

Times you write are a local `dateTime` with a `timeZone`, or a whole-day `date`. Times you read arrive as `Date`s.

- `calendarEvents.list` takes a `start` and `end` range and pages with `paginate`.
- `onlineMeeting: true` adds a Teams link on Microsoft 365 and a Meet link on Google.

[Calendar features](https://workwae.com/features/calendar)

calendar.ts

```ts
import { paginate } from "@workwae/sdk";

for await (const event of paginate(workwae.calendarEvents.list, {
  accountId,
  start: new Date("2026-11-01T00:00:00Z"),
  end: new Date("2026-12-01T00:00:00Z"),
})) {
  console.log(event.start.dateTime, event.subject, event.kind);
}

const weekly = await workwae.calendarEvents.create({
  accountId,
  calendarId,
  subject: "Weekly sync",
  start: {
    dateTime: "2026-11-02T10:00:00",
    timeZone: "Europe/Berlin",
  },
  end: {
    dateTime: "2026-11-02T10:30:00",
    timeZone: "Europe/Berlin",
  },
  attendees: [{ name: "Ada", address: "ada@example.com" }],
  recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10"],
  onlineMeeting: true, // a Teams (Microsoft) or Meet (Google) link
});
```

Contacts

## Search, create, change.

`q` matches part of any name, the organization or any email address, case-insensitively. A new contact needs a name, an organization, an email address or a phone number.

[Contacts features](https://workwae.com/features/contacts)

contacts.ts

```ts
import { paginate } from "@workwae/sdk";

const matches = paginate(workwae.contacts.list, {
  accountId,
  q: "lovelace",
});
for await (const match of matches) {
  console.log(match.displayName);
}

const contact = await workwae.contacts.create({
  accountId,
  givenName: "Ada",
  familyName: "Lovelace",
  emails: [{ address: "ada@example.com", type: "work" }],
  organization: "Analytical Engines Ltd",
});

await workwae.contacts.update({
  accountId,
  contactId: contact.id,
  jobTitle: "Mathematician",
});
```

Connect sessions

## Let a user connect their own mailbox.

Allowlist your redirect URI once, create a hosted session and send the user to its `url`. Without a `redirectUri` the user ends on Workwae’s confirmation page.

- Follow the outcome with `connectSessions.get` or the `account.connected` event.
- `externalRef` ties the session to your own user id.

[Connect features](https://workwae.com/features/connect)

connect.ts

```ts
// Once, with an admin-scoped key: the redirect URIs you allow
await workwae.settings.update({
  redirectUris: ["https://app.example.com/mail/connected"],
});

const session = await workwae.connectSessions.create({
  redirectUri: "https://app.example.com/mail/connected",
  providers: ["google", "microsoft", "imap"],
  externalRef: "user_42",
});
// Redirect the browser to session.url

// Later: what the session did
const outcome = await workwae.connectSessions.get({
  sessionId: session.id,
});
console.log(outcome.status, outcome.accountId);
```

Bookings

## Offer slots, book one, cancel it.

A scheduling link stores which accounts can be booked, for how long and when. `bookings.create` re-checks the slot, creates the calendar event with the invitee as an attendee and sends `booking.created`.

- A slot booked in the meantime answers `slot_taken`: list the slots again.
- Links belong to the workspace, not one account, so these calls take an API key, not an account token.
- `bookings.cancel` deletes the event, tells its attendees and sends `booking.cancelled`.

[Bookings](https://workwae.com/features/bookings) [Scheduling guide](https://docs.workwae.com/guides/scheduling)

bookings.ts

```ts
const link = await workwae.schedulingLinks.create({
  name: "Sales intro",
  accountIds: [aliceAccountId, bobAccountId],
  mode: "any", // round-robin; "all" needs every account free
  durationMinutes: 30,
  timeZone: "America/New_York",
  hours: { mon: [{ start: "09:00", end: "17:00" }] },
  title: "Intro call with {{invitee}}",
  onlineMeeting: true,
});

const { items: slots } = await workwae.schedulingLinks.slots({
  linkId: link.id,
  from: new Date("2026-11-02T00:00:00Z"),
  to: new Date("2026-11-09T00:00:00Z"),
});

const [slot] = slots;
if (slot) {
  const booking = await workwae.bookings.create({
    linkId: link.id,
    start: slot.start,
    invitee: { name: "Ada Lovelace", address: "ada@example.com" },
    notes: "Interested in the team plan",
  });

  await workwae.bookings.cancel({ bookingId: booking.id });
}
```

Webhooks

## Verify a delivery, then trust its type.

`parseWebhook` checks the Standard Webhooks signature and returns the event with its dates as `Date`s. Pass the raw body exactly as received.

- A type this SDK knows has `known: true` and narrows on `type`; a type added later has `known: false` and unchecked `data`.
- A failed check throws `WebhookVerificationError`; its `failure` is `missing_headers`, `timestamp`, `signature` or `payload`.
- `verifyWebhook` answers a boolean and `checkWebhookSignature` says why it failed. All three take `toleranceSeconds`, 300 by default.
- `headers` is a `Headers` object or a plain record such as Node’s `req.headers`.

[Webhooks and change feed](https://workwae.com/features/webhooks)

hooks/workwae.ts

```ts
import {
  parseWebhook,
  WebhookVerificationError,
} from "@workwae/sdk";

const secret = process.env.WORKWAE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  // The raw body, exactly as received
  const body = await request.text();
  try {
    const event = await parseWebhook(secret, request.headers, body);
    if (event.known && event.type === "send.failed") {
      console.log(event.data.outboxId, event.data.error.code);
    }
    // event.known is false for a type added after this SDK version
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }
}
```

Pagination

## Cursors, followed for you.

Listings answer `{ items, nextCursor }`. The helpers take the list method itself, so filters and item types stay typed.

- `paginate` yields every item; `pages` yields each page, so you can store `nextCursor` between pages.
- `changes` reads the change feed until an empty page. `feed.cursor` moves past a page only after its last event was consumed, so saving it after processing gives at-least-once delivery.

`paginate` and `pages` never end on the change feed, whose `nextCursor` is never null. Use `changes`.

pagination.ts

```ts
import { changes, pages, paginate } from "@workwae/sdk";

// Every item, to the last page
const unread = paginate(workwae.messages.list, {
  accountId,
  unread: true,
});
for await (const message of unread) {
  console.log(message.subject);
}

// Page by page
const all = pages(workwae.accounts.list, { limit: 200 });
for await (const page of all) {
  console.log(page.items.length, page.nextCursor);
}

// The change feed, until caught up
const feed = changes(workwae.changes.list, {
  cursor: await loadCursor(),
});
for await (const event of feed) await handle(event);
// Save it after processing, and resume from it next time
await saveCursor(feed.cursor);
```

Errors and retries

## Refusals keyed by stable reasons.

An API refusal is thrown as an `ORPCError`. Errors that are not API answers, such as network failures or an aborted signal, are thrown as they are.

- `code` is the stable reason, such as `account_not_found`; `status` its HTTP status; `data` its details, for reasons that have any.
- `requestId` is the answer’s `X-Request-Id`; quote it to support.
- `safe()` with `isDefinedError()` narrows to the reasons an operation declares; `isApiError(error, reason)` checks for one reason anywhere.
- **Retries.** The client retries 429 and 503 answers, which the API sends before acting: twice by default, waiting for `Retry-After`, or backing off exponentially with jitter. A `Retry-After` longer than `maxDelayMs` (60 000 ms) is not waited for: the error is thrown.

errors.ts

```ts
import { isApiError, isDefinedError, safe } from "@workwae/sdk";

// safe() resolves to [error, data]; isDefinedError() narrows
// to the reasons the operation declares
const [error, account] = await safe(
  workwae.accounts.get({ accountId }),
);
if (isDefinedError(error)) {
  if (error.code === "account_not_found") {
    // ...
  }
} else if (error) {
  throw error;
}

// isApiError(error, reason) checks for one reason anywhere
// and narrows data to it
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.error(error.code, error.status, error.requestId);
  } else {
    throw error;
  }
}
```

Versioning

## An alpha: the surface can still change.

The current version is `0.1.0-alpha.1`. The SDK follows semver from 0.1.0; while it is 0.x, a minor version may carry breaking changes, always listed in the changelog. Releases are tagged `sdk-v<version>` in the repository.

### [SDK guide](https://docs.workwae.com/guides/sdk)

Options, per-call settings, retries and every helper, in the docs.

### [API reference](https://docs.workwae.com/reference)

Every operation, with its fields and error reasons.

### [OpenAPI document](https://api.workwae.com/v1/openapi.json)

The machine-readable spec, for code generators and other languages.

### [Changelog](https://docs.workwae.com/changelog)

What changed in the API and the SDK, including breaking changes.

Early access

## Get a workspace and an API key.

We are onboarding a small number of teams. Tell us what you are building.

[Request early access](https://workwae.com/early-access/) [Follow the quickstart](https://workwae.com/quickstart)
