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
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
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
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 });
OptionMeaning
apiKeyAn API key (wk_...), an account token (wat_...), or a function returning one per request.
baseUrlDefaults to https://api.workwae.com. Set it only to route through your own proxy or gateway.
headersExtra headers sent with every request.
fetchReplaces 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
// 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

mail.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 Dates.

  • 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

calendar.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

contacts.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

connect.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 Scheduling guide

bookings.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 Dates. 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

hooks/workwae.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
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
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.

Early access

Get a workspace and an API key.

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