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.
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.
// 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" }],
});
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.
Names (given, middle, family, nickname), typed email addresses and phone numbers, organization, job title, department, birthday, notes and a photo, on every source.
Match part of any name, the organization or any email address, case-insensitively, with cursor pages. Lists never wait on the provider.
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.
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.
Fetched from the provider on first request, then cached, encrypted, until the photo changes. Served inline only when it really is an image.
One contacts.initialized ends the first pass, then
contact.created, .updated
(naming the fields that changed) and .deleted.
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.
Contacts of Microsoft 365 shared mailboxes, app-only Microsoft accounts and Google Workspace domain-wide delegation, each with the scope it needs spelled out.
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.
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. |
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.
Google and Microsoft accounts connected through hosted or headless OAuth, with contacts on for the account when the user signs in.
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.
Accounts added through an organization connection need the application permission
Contacts.ReadWrite; without it, the contacts error says
so.
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.
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.
With contacts off, contact routes answer contacts_disabled
(409); on a plan without contacts, plan_module_unavailable
(403).
Folders come back with the default first. Filter a list by one, or create a contact straight into it.
provider_unsupported.
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);
}
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.
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;
}
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.
application/octet-stream.
photo_not_found, so skip the call
when hasPhoto is false.
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" }],
});
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 |
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.
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.
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.
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.
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.
Keep people records in step with the address books reps already use.
Bring candidate threads and interviews into your ATS.
Give an agent the context of who someone is before it drafts a reply.
Sync, search and send from the same accounts.
Every calendar, recurring series and free/busy, next to the contacts.
Add whole domains and shared mailboxes without a sign-in per user.
workwae is in early access. Tell us what you are building, and read the docs in the meantime.