npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@10xmedia/payload-carddav-sync

v0.0.4

Published

Payload CMS plugin for bidirectional CardDAV synchronization

Readme

Payload CardDAV Sync Banner

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 afterChange hook 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-sync

Quick 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-sync

Configuration 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 by toVCard. Fields absent from toVCard are preserved on the server. This protects against accidental data loss.

If you set mergeStrategy: 'replace', toVCard fully 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. See mergeStrategy for 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 undefined from toVCard → removed from the contact.
  • A field not returned from toVCard at 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 (CELLcell). 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 by toVCard(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 |