@10xmedia/payload-carddav-sync
v0.0.4
Published
Payload CMS plugin for bidirectional CardDAV synchronization
Readme

A Payload CMS plugin for bidirectional CardDAV synchronization. Keeps a Payload collection in sync with a CardDAV address book (Nextcloud, Mailcow/SOGo, iCloud, Baikal, etc.).
How it works
- Payload → CardDAV: every time a document is created, updated, or deleted in the configured collection, the change is pushed to CardDAV immediately via hooks.
- CardDAV → Payload: a Payload Jobs task runs on a cron schedule, fetches the address book, and updates Payload for any contacts whose ETag changed.
Changes made in Payload win immediately. Changes made in a CardDAV client (phone, Thunderbird, etc.) are pulled in on the next scheduled sync.
Important: the scheduled sync only propagates changes from CardDAV into Payload. On the first job run, all CardDAV contacts are imported into Payload automatically. Documents that exist in Payload but have never been pushed to CardDAV (e.g. created via a script that bypassed the plugin hooks) will not be synced to CardDAV automatically — only creates and updates flowing through the
afterChangehook push to CardDAV. To get existing Payload documents into CardDAV, edit and save each one to trigger the hook.
Installation
pnpm add @10xmedia/payload-carddav-sync
# or
npm install @10xmedia/payload-carddav-syncQuick setup
1. Add the plugin to your config
import type { Contact } from './payload-types'
import { payloadCarddavSync } from '@10xmedia/payload-carddav-sync'
export default buildConfig({
plugins: [
payloadCarddavSync<Contact>({
auth: {
serverUrl: process.env.CARDDAV_SERVER_URL!,
addressbookPath: process.env.CARDDAV_ADDRESSBOOK_PATH!,
username: process.env.CARDDAV_USERNAME!,
password: process.env.CARDDAV_PASSWORD!,
},
collection: 'contacts',
fieldMapping: {
fromVCard: (vcard, req) => ({
firstName: vcard.fn?.value?.split(' ')[0],
lastName: vcard.fn?.value?.split(' ').slice(1).join(' ') || undefined,
email: vcard.email?.[0]?.value,
phone: vcard.tel?.[0]?.value,
}),
toVCard: (doc, req) => ({
fn: { value: [doc.firstName, doc.lastName].filter(Boolean).join(' ') },
email: doc.email ? [{ value: doc.email, type: 'home' }] : undefined,
tel: doc.phone ? [{ value: doc.phone, type: 'cell' }] : undefined,
}),
},
sync: {
cron: '*/5 * * * *',
queue: 'carddav-sync',
},
}),
],
})2. Configure the job runner
The plugin registers a Payload Jobs task but does not configure the runner — this is your responsibility since it depends on your deployment.
Dedicated server — use autoRun:
export default buildConfig({
jobs: {
autoRun: [
{ cron: '* * * * *', queue: 'carddav-sync' },
],
},
plugins: [/* ... */],
})Serverless — trigger via API:
Set up a cron (Vercel Cron, etc.) to call:
GET /api/payload-jobs/run?queue=carddav-syncConfiguration reference
payloadCarddavSync({
// Required
auth: CardDavAuth
collection: CollectionSlug
fieldMapping: {
fromVCard: (vcard: VCardProperties, req: PayloadRequest) => Partial<TDoc> | Promise<Partial<TDoc>>
toVCard: (doc: TDoc, req: PayloadRequest) => VCardProperties | Promise<VCardProperties>
}
// Optional
debug?: boolean // show cardDavSync fields in admin UI. Default: false
disabled?: boolean // disable sync but keep schema fields intact
mergeStrategy?: 'replace' | 'merge' // how Payload writes to CardDAV. Default: 'merge'
sync?: {
cron?: string // schedule for the sync job. Default: '*/5 * * * *'
queue?: string // Payload job queue name. Default: 'carddav-sync'
concurrency?: number // parallel Payload operations per batch. Default: 10
maxRetries?: number // retry attempts on transient errors. Default: 3
}
})Auth
Basic / Digest:
auth: {
serverUrl: 'https://nextcloud.example.com',
addressbookPath: '/remote.php/dav/addressbooks/users/admin/contacts/',
username: 'admin',
password: 'secret',
authMethod: 'Basic', // or 'Digest'. Default: 'Basic'
}Bearer token:
auth: {
serverUrl: 'https://example.com',
addressbookPath: '/dav/addressbooks/user/default/',
token: 'my-bearer-token',
}Common server paths
| Server | addressbookPath |
|---|---|
| Nextcloud | /remote.php/dav/addressbooks/users/{username}/contacts/ |
| Mailcow / SOGo | /SOGo/dav/{email}/Contacts/personal/ |
| Baikal | /dav.php/addressbooks/{username}/default/ |
| iCloud | /3.0/{dsid}/carddavhome/card/ |
Field mapping
fromVCard maps parsed vCard properties to your Payload doc fields.toVCard maps your Payload doc back to vCard properties for writing to CardDAV.
Both functions receive a req: PayloadRequest as their second argument and can be async. This lets you look up relationships, fetch related documents, or run any async logic before mapping.
Note: by default, the plugin uses
mergeStrategy: 'merge'— before writing to CardDAV, it fetches the existing contact and overlays only the fields returned bytoVCard. Fields absent fromtoVCardare preserved on the server. This protects against accidental data loss.If you set
mergeStrategy: 'replace',toVCardfully rebuilds the vCard and any field not returned is permanently deleted from the contact on CardDAV. Use this only when your mapping covers all fields you care about. SeemergeStrategyfor details.
mergeStrategy
Controls how Payload writes back to CardDAV when a document changes. Default: 'merge'.
| Value | Behaviour |
|---|---|
| 'replace' | The vCard is fully rebuilt from toVCard(doc). Fields not returned are deleted. |
| 'merge' | The existing contact is fetched from CardDAV first. toVCard(doc) is overlaid on top — present fields overwrite, absent fields are preserved. |
payloadCarddavSync({
mergeStrategy: 'replace', // opt out of merge if your mapping covers all fields
// ...
})Merge rules:
- A field returned with a value from
toVCard→ overwrites the CardDAV value. - A field returned as
undefinedfromtoVCard→ removed from the contact. - A field not returned from
toVCardat all → kept from the existing CardDAV contact.
Caveats: merge adds an extra GET request per document update. If the contact no longer exists on CardDAV (deleted remotely), the plugin falls back to replace behavior for that update.
VCardEntry
All vCard properties are represented as VCardEntry objects to preserve TYPE parameters (e.g. home, work, cell):
type VCardEntry = {
value: string
type?: string // e.g. 'home', 'work', 'cell', 'internet'
}Multi-value fields — email, impp, tel, url, nickname — are always VCardEntry[], even when only one value is present. Scalar fields — fn, note, org, etc. — are VCardEntry. Three fields have dedicated structured types described below.
VCardNameEntry — the n field
The structured name field N (RFC 6350 §6.2.2) is parsed into named components:
type VCardNameEntry = {
familyName?: string // "Doe"
givenName?: string // "John"
additionalNames?: string // middle name
honorificPrefix?: string // "Dr"
honorificSuffix?: string // "Jr"
}fromVCard: (vcard) => ({
firstName: vcard.n?.givenName,
lastName: vcard.n?.familyName,
}),
toVCard: (doc) => ({
fn: { value: [doc.firstName, doc.lastName].filter(Boolean).join(' ') },
n: { familyName: doc.lastName, givenName: doc.firstName },
}),VCardAdrEntry — the adr field
The address field ADR is a structured, repeatable field. The parser returns VCardAdrEntry[] — one entry per address:
type VCardAdrEntry = {
poBox?: string
extendedAddress?: string
street?: string
city?: string
region?: string
postalCode?: string
country?: string
type?: string // e.g. 'home', 'work'
}fromVCard: (vcard) => ({
street: vcard.adr?.[0]?.street,
city: vcard.adr?.[0]?.city,
country: vcard.adr?.[0]?.country,
}),
toVCard: (doc) => ({
adr: doc.street ? [{ type: 'home', street: doc.street, city: doc.city, country: doc.country }] : undefined,
}),categories field
CATEGORIES is comma-separated and parsed into a plain string[]:
fromVCard: (vcard) => ({
tags: vcard.categories ?? [],
}),
toVCard: (doc) => ({
categories: doc.tags,
}),impp field
IMPP (instant messaging) is a repeatable field, returned as VCardEntry[]. The value contains the full URI (xmpp:[email protected], telegram:+380...), type carries the protocol when the client sends one:
fromVCard: (vcard) => ({
telegram: vcard.impp?.find(e => e.type === 'telegram')?.value?.replace('telegram:', ''),
}),
toVCard: (doc) => ({
impp: doc.telegram ? [{ type: 'telegram', value: `telegram:${doc.telegram}` }] : undefined,
}),General field mapping examples
fieldMapping: {
fromVCard: (vcard, req) => ({
// Scalar fields: access .value
name: vcard.fn?.value,
org: vcard.org?.value,
note: vcard.note?.value,
// Structured name
firstName: vcard.n?.givenName,
lastName: vcard.n?.familyName,
// Multi-value VCardEntry[] fields: pick by index or type
email: vcard.email?.[0]?.value,
phone: vcard.tel?.[0]?.value,
homePhone: vcard.tel?.find(e => e.type === 'home')?.value,
workEmail: vcard.email?.find(e => e.type === 'work')?.value,
// Structured address
street: vcard.adr?.[0]?.street,
city: vcard.adr?.[0]?.city,
country: vcard.adr?.[0]?.country,
// Categories
tags: vcard.categories ?? [],
}),
toVCard: (doc, req) => ({
fn: { value: [doc.firstName, doc.lastName].filter(Boolean).join(' ') },
n: { familyName: doc.lastName, givenName: doc.firstName },
org: doc.org ? { value: doc.org } : undefined,
note: doc.note ? { value: doc.note } : undefined,
email: doc.email ? [{ value: doc.email, type: 'home' }] : undefined,
tel: [
doc.mobilePhone ? { value: doc.mobilePhone, type: 'cell' } : undefined,
doc.homePhone ? { value: doc.homePhone, type: 'home' } : undefined,
].filter(Boolean) as VCardEntry[],
adr: doc.street
? [{ type: 'home', street: doc.street, city: doc.city, country: doc.country }]
: undefined,
categories: doc.tags?.length ? doc.tags : undefined,
}),
}Async mapping and relationship lookups
Both functions can be async. The req parameter gives you a full PayloadRequest, so you can fetch related documents or run any server-side logic:
fieldMapping: {
// Map an org relationship ID → company name for the CardDAV org field
toVCard: async (doc, req) => {
let orgName: string | undefined
if (doc.org) {
const orgId = typeof doc.org === 'object' ? doc.org.id : doc.org
const org = await req.payload.findByID({
collection: 'organisations',
id: orgId,
overrideAccess: true,
})
orgName = org?.name
}
return {
fn: { value: [doc.firstName, doc.lastName].filter(Boolean).join(' ') },
org: orgName ? { value: orgName } : undefined,
}
},
// Map the vCard org name → a relationship ID by looking up the collection
fromVCard: async (vcard, req) => {
let orgId: string | undefined
if (vcard.org?.value) {
const result = await req.payload.find({
collection: 'organisations',
limit: 1,
overrideAccess: true,
where: { name: { equals: vcard.org.value } },
})
orgId = result.docs[0]?.id
}
return {
firstName: vcard.fn?.value?.split(' ')[0],
lastName: vcard.fn?.value?.split(' ').slice(1).join(' ') || undefined,
org: orgId,
}
},
}TYPE values are normalized to lowercase by the parser (CELL → cell). When serializing, the value is written as-is (TYPE=cell).
Hidden fields
The plugin injects a cardDavSync group field into the configured collection:
| Field | Purpose |
|---|---|
| cardDavSync.uid | vCard UID — stable remote identifier |
| cardDavSync.url | Full resource URL on the CardDAV server |
| cardDavSync.etag | Last known ETag for change detection |
| cardDavSync.lastSyncedAt | Timestamp of last successful sync |
All fields are hidden in the admin UI by default. Set debug: true to make them visible.
Soft deletes
If your collection has trash: true, Payload treats deletes as updates that set deletedAt — the plugin handles this automatically.
| Action | What happens |
|---|---|
| Trash a contact in Payload | Contact deleted from CardDAV; cardDavSync fields cleared |
| Restore a trashed contact | Contact recreated on CardDAV from Payload fields only (see warning below) |
| Empty trash (hard delete) | afterDelete hook fires; contact deleted from CardDAV |
Payload → CardDAV: the plugin detects the soft-delete transition by comparing doc.deletedAt to previousDoc.deletedAt in the afterChange hook.
CardDAV → Payload (scheduled sync): when a contact is removed from CardDAV, the plugin checks req.payload.collections[slug].config.trash at runtime. If trash: true, it updates the document with deletedAt (soft-delete). Otherwise it hard-deletes. No extra config needed — the plugin reads the collection config automatically.
Warning: restoring a trashed contact does not restore the original CardDAV data.
When a contact is trashed, it is deleted from CardDAV and the sync state (
cardDavSync) is cleared. If the contact is later restored in Payload, the plugin recreates it on CardDAV using only the fields returned bytoVCard(doc)— i.e. whatever is stored in your Payload collection at that point.Any vCard data that was not mapped to a Payload field (birthday, address, X- extensions, etc.) is permanently lost on restore. If preserving the full vCard is important, consider keeping the original fields in the collection and mapping them through via
fromVCard/toVCard.
disabled option
Setting disabled: true keeps the cardDavSync fields in the schema (important for migrations) but disables all hooks and the sync job.
TypeScript
Pass your generated Payload type to get typed toVCard / fromVCard:
import type { Contact } from './payload-types'
import type { VCardEntry } from '@10xmedia/payload-carddav-sync'
payloadCarddavSync<Contact>({ /* ... */ })Import VCardEntry when you need to annotate values in toVCard:
toVCard: (doc, req): VCardProperties => ({
fn: { value: doc.name },
tel: doc.phones.map((p): VCardEntry => ({ value: p.number, type: p.type })),
})Sync behavior summary
| Event | What happens |
|---|---|
| Create doc in Payload | PUT to CardDAV, ETag and URL stored |
| Update doc in Payload | PUT to CardDAV with If-Match; on 412 force-overwrites (Payload wins) |
| Trash doc in Payload (trash: true) | DELETE from CardDAV, cardDavSync cleared |
| Restore trashed doc | Contact recreated on CardDAV with a new UID |
| Delete doc in Payload (hard delete) | DELETE from CardDAV |
| CardDAV ETag changed | Next scheduled sync updates Payload doc |
| Contact deleted from CardDAV | Next scheduled sync: soft-deletes Payload doc if trash: true, otherwise hard-deletes |
| New contact in CardDAV | Next scheduled sync creates Payload doc |
