1. Features
  2. Contacts
Contacts

Every address book, behind one contacts API.

workwae syncs contacts from Google People, Microsoft 365 contact folders and CardDAV address books into one index with one shape. Search it, write back to the provider, and get an event for every change after a quiet first pass.

contacts.ts
// Opt-in: for the tenant, or for one account
await workwae.accounts.update({
  accountId,
  settings: { contactsEnabled: true },
});

// Part of a name, organization or address
const { items } = await workwae.contacts.list({
  accountId,
  q: "acme",
});

// Written to the provider, then listed at once
await workwae.contacts.update({
  accountId,
  contactId: items[0].id,
  jobTitle: "VP Operations",
  phones: [{ number: "+1 713 555 0142" }],
});
What you get

Address books you can rely on

The same contact model, the same calls and the same events, whether the account is on Google, Microsoft 365 or an IMAP server with CardDAV.

One contact model

Names (given, middle, family, nickname), typed email addresses and phone numbers, organization, job title, department, birthday, notes and a photo, on every source.

Search from the index

Match part of any name, the organization or any email address, case-insensitively, with cursor pages. Lists never wait on the provider.

Folders and address books

Microsoft 365 contact folders, subfolders included, and CardDAV address books are the account's folders. List and filter by them, and create contacts in the one you choose.

Write back, read your writes

Create, update and delete contacts at the provider. The index changes in the same call, so the next list already shows it. A field set to null clears it.

Contact photos

Fetched from the provider on first request, then cached, encrypted, until the photo changes. Served inline only when it really is an image.

An event for every change

One contacts.initialized ends the first pass, then contact.created, .updated (naming the fields that changed) and .deleted.

Off until you turn it on

contactsEnabled is off by default, per tenant or per account. Sign-ins ask for contacts access only when contacts are on and your plan includes them.

Shared and organization mailboxes

Contacts of Microsoft 365 shared mailboxes, app-only Microsoft accounts and Google Workspace domain-wide delegation, each with the scope it needs spelled out.

Efficient by design

Sync tokens, deltas and etags everywhere, so an idle pass reads only what changed, and an expired token re-reads and diffs instead of announcing everything again.

How it works

Three sources, one behavior

Each source has its own way to report changes and its own limits. This is how workwae reads each one, and what it means for how fast changes reach you.

  Google People Microsoft 365 CardDAV
What syncs The account's contacts (people/me/connections). The default Contacts folder and every folder below it. Every address book in the account's home set, found from the provider table, /.well-known/carddav or DNS SRV.
Finding changes One connections.list with a sync token; an expired token (they live about a week) re-reads the list and diffs it against the index. One delta per folder. A contact moved between folders is one update, not a delete and a create. sync-collection (RFC 6578) where offered, else ctag and etag comparison; changed cards are read 50 at a time.
Push None: Google offers no push for contacts. A Graph change-notification subscription on the mailbox's contacts. None: CardDAV has no push.
Polling Daily, since People requests are billed, plus a pass after every API write. A backstop every 10 minutes with push, every minute without it. Every 15 minutes, plus a pass after every API write.
Folders None; contacts carry a null folderId. The folder tree; new contacts go to the default folder unless you name one. Address books; the default is the first in URL order and stays the default as books are added.
Limits and quirks Birthdays may have no year (--MM-DD). At most 3 email addresses, one mobile number and no "other" number; more is refused with provider_unsupported. Email types are null. Writes change only the named fields and are sent with If-Match; another client's edit is merged up to three times before a 409.
Photos hasPhoto is known from the sync; Google's placeholder pictures don't count. hasPhoto is null: Graph only says by serving the photo. From the vCard's PHOTO.
Access needed https://www.googleapis.com/auth/contacts Contacts.ReadWrite, or Contacts.ReadWrite.Shared for shared mailboxes. The IMAP password, or a separate DAV password.
Accounts and scopes

Which accounts get contacts

Contacts follow the account, whichever way it was connected. When a grant lacks contacts access, mail keeps syncing and the account reports a contacts sync error.

  • Personal sign-ins

    Google and Microsoft accounts connected through hosted or headless OAuth, with contacts on for the account when the user signs in.

  • Microsoft 365 shared mailboxes

    A shared mailbox opened through a user's grant gets contacts when that grant carries Contacts.ReadWrite.Shared; otherwise it is created with contacts off.

  • Microsoft admin consent

    Accounts added through an organization connection need the application permission Contacts.ReadWrite; without it, the contacts error says so.

  • Google Workspace delegation

    Contacts are off on a domain-wide delegation connection until you turn them on and the Workspace admin authorizes the contacts scope for the key.

  • IMAP accounts with CardDAV

    Adding DAV settings, or a hosted connect that finds them, turns contacts on unless the account's settings say otherwise. Without DAV settings there are no contacts.

  • Clear refusals

    With contacts off, contact routes answer contacts_disabled (409); on a plan without contacts, plan_module_unavailable (403).

Build it

Work with folders and address books

Folders come back with the default first. Filter a list by one, or create a contact straight into it.

  • Microsoft 365 folders include every subfolder of the mailbox's Contacts folder, read recursively on every pass.
  • CardDAV address books are folders too; a book that refuses access or disappears is skipped for that pass while the others sync.
  • Google has no contact folders: the list is empty and naming a folder on create answers provider_unsupported.
  • A new contact needs a name, an organization, an email address or a phone number, so it can be found again.
folders.ts
import { paginate } from "@workwae/sdk";

const { items } = await workwae.contactFolders.list({ accountId });
const vendors = items.find((f) => f.name === "Vendors");

// Into that folder, or the default one when folderId is omitted
const contact = await workwae.contacts.create({
  accountId,
  folderId: vendors?.id,
  givenName: "Sam",
  familyName: "Okafor",
  emails: [{ address: "sam@globex.example", type: "work" }],
  organization: "Globex",
  birthday: "--04-17",
});

// Every contact in one folder, page by page
for await (const c of paginate(workwae.contacts.list, {
  accountId,
  folderId: vendors?.id,
})) {
  console.log(c.displayName, c.emails, c.phones);
}
Build it

Mirror an address book into your app

Load everything once after contacts.initialized, then apply each contact.* event. Each one carries the whole contact, so there's nothing to fetch.

  • contact.updated names the fields that changed in changes, including photo when the picture changed but hasPhoto did not.
  • contact.deleted carries the contactId and the folder it was in.
  • Your own writes are in the index when the call returns, so you can update your copy from the answer.
  • Signed deliveries, filterable by event type and account, with replays and an ordered change feed behind them.

Read the webhooks guide

contacts-webhook.ts
import { parseWebhook } from "@workwae/sdk";

const event = await parseWebhook(secret, request.headers, body);
if (!event.known) return;

switch (event.type) {
  case "contacts.initialized":
    await importAll(event.accountId, event.data.contacts);
    break;
  case "contact.created":
  case "contact.updated":
    await upsertPerson(event.accountId, event.data.contact);
    if (
      event.type === "contact.updated" &&
      event.data.changes.includes("photo")
    ) {
      await refreshAvatar(event.data.contact.id);
    }
    break;
  case "contact.deleted":
    await removePerson(event.accountId, event.data.contactId);
    break;
}
Build it

Photos without hammering the provider

The photo route answers from the cache when it holds the current picture, and asks the provider only on a miss or after the photo changed.

  • Cached per contact in object storage, encrypted, under the account, so deleting the account removes them too. Without object storage every request goes to the provider.
  • Safe to show: JPEG, PNG, GIF, WebP and BMP are served inline with a sandboxing Content-Security-Policy; any other type is served as application/octet-stream.
  • No photo answers 404 photo_not_found, so skip the call when hasPhoto is false.
photo.ts
const ref = { accountId, contactId };
const contact = await workwae.contacts.get(ref);

// null on Microsoft 365: Graph only says by serving it
if (contact.hasPhoto !== false) {
  const photo = await workwae.contacts.photo(ref);
  const data = await photo.arrayBuffer();
  await saveAvatar(contactId, photo.type, data);
}

// null clears a field; emails and phones replace the whole list
await workwae.contacts.update({
  accountId,
  contactId,
  nickname: null,
  emails: [{ address: "sam.okafor@globex.example" }],
});
Events

Contact events

Delivered as signed webhooks and listed in the change feed, in the same envelope as every other event.

Event When Data
contacts.initialized The account's first contacts sync finished; nothing before it is announced contact by contact. contacts: how many were indexed
contact.created A contact appeared, at the provider or through the API. contact
contact.updated A contact changed, or moved to another folder. contact, changes
contact.deleted A contact went away. contactId, folderId
FAQ

Questions about contacts

Why do Google contact changes take longer to arrive?

Google offers no push for contacts and bills People API requests, so workwae reads Google contacts once a day. Changes made through workwae are in the index as soon as the call returns, and each write also triggers a pass.

Are Google contact labels synced?

No. A contact belongs to at most one folder, and Google has no contact folders, so Google contacts carry a null folderId. Folders come from Microsoft 365 contact folders and CardDAV address books.

What happens when someone edits the same contact elsewhere?

On CardDAV, a write changes only the fields you named and is sent with the version it read; if another client changed the card meanwhile, workwae applies your change to the new version, up to three times, before answering with a conflict. Either way, the next sync brings the other client's edits in as a contact.updated.

Does workwae keep a copy of contact photos?

Only after one is requested, and only when object storage is configured. The cached photo is encrypted, replaced when the photo changes, and deleted with the contact or the account.

Does turning on contacts need a new sign-in?

A sign-in asks for contacts access only when contacts are on for the account. Turn them on before the user connects, or have the user reconnect afterwards; until then the account reports a contacts sync error while mail keeps working. CardDAV uses the stored login, so IMAP accounts need nothing more.

Bring every address book into your product

workwae is in early access. Tell us what you are building, and read the docs in the meantime.