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.
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
fetchand Web Crypto, nothing else. - ESM only. Its runtime dependencies are
zodand@orpc/*. - Hosted API.
baseUrldefaults tohttps://api.workwae.com.
npm install @workwae/sdk 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,calendarEventsandsuppressions) keep path and query apart as{ params, query }. - A second argument takes
signal, which aborts the call and any retry wait, andcontext.idempotencyKey.
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. |
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,sendandadmin, 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 thewritescope. 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.
// 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();
},
}); 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.idempotencyKeyto make a call safe to repeat across restarts; the API keeps the first answer for 24 hours.
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" } },
); 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.listtakes astartandendrange and pages withpaginate.onlineMeeting: trueadds a Teams link on Microsoft 365 and a Meet link on Google.
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
}); 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.
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",
}); 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.getor theaccount.connectedevent. externalRefties the session to your own user id.
// 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); 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.canceldeletes the event, tells its attendees and sendsbooking.cancelled.
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 });
} 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: trueand narrows ontype; a type added later hasknown: falseand uncheckeddata. - A failed check throws
WebhookVerificationError; itsfailureismissing_headers,timestamp,signatureorpayload. verifyWebhookanswers a boolean andcheckWebhookSignaturesays why it failed. All three taketoleranceSeconds, 300 by default.headersis aHeadersobject or a plain record such as Node’sreq.headers.
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;
}
} Cursors, followed for you.
Listings answer { items, nextCursor }. The helpers take the list method itself, so filters and item types stay typed.
paginateyields every item;pagesyields each page, so you can storenextCursorbetween pages.changesreads the change feed until an empty page.feed.cursormoves 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.
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); 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.
codeis the stable reason, such asaccount_not_found;statusits HTTP status;dataits details, for reasons that have any.requestIdis the answer’sX-Request-Id; quote it to support.safe()withisDefinedError()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. ARetry-Afterlonger thanmaxDelayMs(60 000 ms) is not waited for: the error is thrown.
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;
}
} 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
Options, per-call settings, retries and every helper, in the docs.
API reference
Every operation, with its fields and error reasons.
OpenAPI document
The machine-readable spec, for code generators and other languages.
Changelog
What changed in the API and the SDK, including breaking changes.
Get a workspace and an API key.
We are onboarding a small number of teams. Tell us what you are building.