CRM & Marketing Quickstart

  1. What you're building
  2. The four layers
  3. Install the packages
  4. Wire them into payload.config
  5. Define an intake form
  6. The intake → CRM seam
  7. Capture a lead
  8. Marketing sync

This quickstart follows a lead from a form submission on a page to a contact in your CRM. It assumes you have the block loop from Get Started working. None of these packages are gated block packs — they're capability layers, so they install with just the registry line in your .npmrc, no token required.

The four layers

  • @wabbit/tome-forms — renders and validates the form.
  • @wabbit/tome-intake — the submit pipeline: verify → persist → route → notify.
  • @wabbit/tome-crm — contacts, accounts, opportunities, activities.
  • @wabbit/tome-marketing — campaigns, segments, and optional email-service-provider sync.

A tome-starter fork ships all four wired already (in src/payload.config.ts plus the config objects under src/lib/), with a working demo form. The steps below are the reference for how they connect.

Install the packages

npm install @wabbit/tome-crm @wabbit/tome-marketing @wabbit/tome-forms @wabbit/tome-intake

Wire them into payload.config

Order matters: createIntakeLayer must run before createFormsLayer, so the forms layer's config-time check for the intake layer passes. Each layer returns collections you spread into your config:

import { createIntakeLayer } from '@wabbit/tome-intake'
import { createFormsLayer } from '@wabbit/tome-forms'
import { createCrmLayer } from '@wabbit/tome-crm'
import { createMarketingLayer } from '@wabbit/tome-marketing'

// 1. Intake FIRST — captures a lazy Payload resolver and registers the layer.
const intakeLayer = createIntakeLayer({
  forms: intakeFormDefinitions,          // your app's intake forms
  getPayload: async () => {
    const { getPayload } = await import('payload')
    const { default: cfg } = await import('@payload-config')
    return getPayload({ config: cfg })
  },
})

// 2. Forms — the render layer (seeds the in-memory form registry).
const formsLayer = createFormsLayer({ forms: formDefinitions, ...TOME_FORMS_SLUGS })

// ...then in your config's `collections` array:
...intakeLayer.collections,   // intake-submissions
...formsLayer.collections,    // tome-forms-*
...createCrmLayer(crmConfig),        // crm-contacts / crm-accounts / crm-opportunities / crm-activities
...createMarketingLayer(marketingConfig), // marketing-campaigns / -campaign-memberships / -segments

intakeFormDefinitions, formDefinitions, crmConfig, and marketingConfig are your app's config objects (the starter keeps them under src/lib/intake, src/lib/forms, src/lib/crm, and src/lib/marketing). All four layers boot cleanly with no external keys.

Define an intake form

An intake form is a field list plus verification, routing, and notification policy. defineIntakeForm creates one; add it to the forms array you pass initIntake:

import { defineIntakeForm } from '@wabbit/tome-intake'

export const contactIntake = defineIntakeForm({
  slug: 'contact',
  fields: [
    { name: 'name', type: 'text', required: true },
    { name: 'email', type: 'email', required: true },
    { name: 'message', type: 'textarea' },
    { name: '_hp', type: 'hidden', honeypot: true },
  ],
  verification: { provider: 'none' },   // the forms layer owns anti-fraud upstream
  routing: { strategy: 'static', staticAssigneeId: 'rep-1' },
  notify: { stakeholderEmail: { to: 'sales@your-domain.com' } },
})

The starter ships a working example under the slug demo-intake, rendered at /intake-demo — a good thing to submit against first.

The intake → CRM seam

The connection from a submission to a CRM contact is an onIntake handler that calls the CRM server helpers. In the starter this lives in src/lib/intake/definitions.ts: after the submission persists, it matches or creates a contact, and (for allowlisted forms) opens an opportunity:

import { matchOrCreateContact, createIntakeOpportunity } from '@wabbit/tome-crm/server'

// inside your intake form's onIntake(submission, { payload }):
const { contact, account } = await matchOrCreateContact(
  payload,
  {
    email: submission.data.email,
    firstName: submission.data.firstName,
    source: 'intake:' + submission.formSlug,
    sourceSubmission: submission.id,
  },
  { config: crmConfig },
)
// optionally, when an account resolved and the form is allowlisted:
await createIntakeOpportunity(payload, { contact, account /* ... */ }, { config: crmConfig })

The whole seam is wrapped in a try/catch so a CRM hiccup never fails the already-saved submission — the lead is captured first, enriched second. Contacts land in crm-contacts; a domain match creates a crm-accounts row; allowlisted forms open a crm-opportunities row in the new stage.

Capture a lead

Render the form with the TomeForm component from @wabbit/tome-forms, bound to the flat submitIntakeAction seam:

import { TomeForm } from '@wabbit/tome-forms'
import { submitIntakeAction } from '@wabbit/tome-intake'

<TomeForm form="contact" action={(data) => submitIntakeAction('contact', data)} />

Submit it (the starter's live example is at /intake-demo). The submission is written to intake-submissions, then the seam fires: open /admin and look in the crm-contacts collection — your lead is there, tagged with its source. That's the full path: page → intake → CRM.

Marketing sync

The marketing layer holds campaigns and segments locally and can push audiences to an email service provider. Sync is optional and key-gated: the Encharge adapter activates only when ENCHARGE_API_KEY is set, the Kit adapter only when KIT_API_KEY is set. With no keys, the layer runs manual-only — audience pushes are silently skipped, nothing crashes, and your build is clean. Notifications degrade the same way: without RESEND_API_KEY, intake's notify step logs instead of emailing.

Next: Learning quickstart or Versioning & updates. Questions? Support is a real person on email.