Rex Automaton
All posts
CRM & Pipeline AutomationJuly 15, 20269 min read

Zenoti Integration: Sync Bookings to Your CRM for Rebooking and Win-Back

We built a Zenoti API integration that streams guests and bookings into a CRM to run rebooking, recall, and win-back campaigns. Uses Zenoti webhooks, token auth, and a CSV backfill path, with guardrails for Zapier limits and webhook failures.

By Jacky Lei

We built and shipped a Zenoti integration that syncs guests and bookings into a CRM so rebooking, recall, and win-back campaigns run automatically. In production it listens to Zenoti webhooks, enriches guests, upserts contacts and timeline events, and triggers sequences based on last visit and service history.

Zenoti retention integration is: using Zenoti's API, webhooks, and scheduled exports to keep your CRM updated with guests and appointments so you can rebook on time, recall by service, and win back lapsed clients.

The problem it solves

Most med-spas still export Zenoti reports, paste lists into a CRM, and click send on one-off emails. That breaks whenever staff get busy, custom fields do not map, or a client rebooks between export and blast. The result: missed rebooks, generic messages, and no structured view of who returned.

TaskManual in Zenoti + CRMAutomated with our Zenoti integration
Guest updatesExport CSV weekly. Import to CRM. Fix duplicatesWebhooks fan-in to a service that upserts contacts in seconds
Rebooking remindersFilter last-visit by hand. Build a list each weekRules trigger when no next appointment and last visit in X days
Recall by serviceBuild report by service codes and date rangeMapped service codes tag the contact and start the right flow
Win-back lapsed clientsAd hoc searches. One-size emailsSegments by lapse windows and service history, with tailored copy
Data hygieneSpreadsheet fixes and mergesIdempotent keys and timeline events keep the CRM accurate

How the automation works

Answer first: Zenoti sends webhooks for guest and appointment changes. A small service verifies the call, fetches the latest guest record via the Zenoti API, normalizes it, and upserts to your CRM with timeline events that power rebooking, recall, and win-back automations. A scheduled CSV email export acts as a safe backfill path.

  • Zenoti webhooks: We subscribe to events like Guest Created or Updated, Appointment Started or Completed, and Class Booking Created or Cancelled. Webhooks are the real-time spine that eliminates export lag.
  • Token auth: We generate an access token against Zenoti using the documented token endpoint. Tokens are stored server-side and rotated.
  • Sync engine: A stateless handler dedupes, enriches, and upserts guests and appointments to the CRM. It is idempotent by design.
  • CRM triggers: We compute segments in code: due to rebook, due for recall by service, and lapsed by 60, 120, or 180 days, then set fields or tags the CRM sequences listen for.
  • CSV backfill: We schedule Zenoti report emails to a service mailbox. Attachments are parsed to backfill history or heal gaps after outages.

Zenoti retention integration workflow: Zenoti webhooks flow into a token-auth sync engine that fetches guest details, upserts to the CRM, and starts rebooking, recall, and win-back campaigns. A scheduled CSV export provides backfill.

Step-by-step: how to build it

1) Create a Zenoti access token

Generate an access token using Zenoti's documented token endpoint. Keep credentials server-only and rotate on schedule.

// auth/zenoti.js
import fetch from "node-fetch";
 
export async function getZenotiToken() {
  const url = "https://api.zenoti.com/v1/tokens"; // per Zenoti docs
  const resp = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      // populate with your Zenoti credentials or API key payload
      // follow https://docs.zenoti.com/reference/generate-an-access-token
      client_id: process.env.ZENOTI_CLIENT_ID,
      client_secret: process.env.ZENOTI_CLIENT_SECRET,
      grant_type: "client_credentials"
    })
  });
  if (!resp.ok) throw new Error(`Zenoti token failed: ${resp.status}`);
  const data = await resp.json();
  return { token: data.access_token, expiresIn: data.expires_in };
}

Key gotcha: do not put Zenoti credentials in client code. Keep them in server env and rotate proactively.

2) Receive Zenoti webhooks and verify

Stand up a minimal HTTPS endpoint to receive guest and appointment notifications. Zenoti documents numerous webhook events and failure handling when deliveries repeatedly fail.

// api/webhooks/zenoti.js
import express from "express";
import { queueSync } from "../jobs/sync.js";
 
const app = express();
app.use(express.json());
 
app.post("/webhooks/zenoti", async (req, res) => {
  // Optionally verify a shared secret if configured
  const evt = req.body; // contains event type and resource ids
  // Only enqueue the event types you map
  const allowed = new Set([
    "Guest Created",
    "Guest Updated",
    "Appointment Completed",
    "Class Booking Cancelled"
  ]);
  if (!allowed.has(evt.eventType)) return res.status(202).end();
  await queueSync(evt);
  res.status(202).end();
});
 
export default app;

Tip: handle retries idempotently and return 2xx quickly. Zenoti can inactivate failing webhooks until issues are resolved, so keep the endpoint healthy.

3) Fetch the latest guest record before writing

On webhook, pull the canonical guest from Zenoti so your CRM write reflects the latest state.

// zenoti/guests.js
import fetch from "node-fetch";
import { getZenotiToken } from "../auth/zenoti.js";
 
export async function getGuest(guestId) {
  const { token } = await getZenotiToken();
  const resp = await fetch(`https://api.zenoti.com/v1/guests/${guestId}`, {
    headers: { Authorization: `Bearer ${token}` }
  });
  if (!resp.ok) throw new Error(`Guest fetch failed ${resp.status}`);
  return await resp.json();
}

Reference: Zenoti documents retrieving guest details at the guests endpoint.

4) Normalize into a CRM-ready payload

Map core fields consistently. If Zenoti custom fields are important, note Zapier does not support custom fields; use the API path for a full map.

// map/payloads.js
export function toCrmContact(guest, lastAppt) {
  return {
    external_id: `zenoti:${guest.id}`,
    email: guest.email,
    phone: guest.phone,
    first_name: guest.first_name,
    last_name: guest.last_name,
    tags: [
      ...(guest.gender ? [`gender:${guest.gender}`] : []),
      ...(lastAppt?.service_name ? [`svc:${lastAppt.service_name}`] : [])
    ],
    properties: {
      last_visit_at: lastAppt?.end_time || null,
      next_appointment_at: guest.next_appointment_at || null,
      zenoti_loyalty_tier: guest.loyalty_tier || null
    }
  };
}

Guardrail: make the external_id deterministic so upserts are idempotent across replays.

5) Upsert to the CRM and write a timeline event

Keep the sink pluggable. We upsert the contact, then append a timeline event for appointment completed. Sequences key off either tags or properties.

// sinks/crm.js
import fetch from "node-fetch";
 
export async function upsertContact(contact) {
  const resp = await fetch(`${process.env.CRM_URL}/contacts`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.CRM_TOKEN}`
    },
    body: JSON.stringify(contact)
  });
  if (!resp.ok) throw new Error(`CRM upsert failed ${resp.status}`);
  return await resp.json();
}
 
export async function addTimelineEvent(contactId, evt) {
  await fetch(`${process.env.CRM_URL}/contacts/${contactId}/events`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.CRM_TOKEN}`
    },
    body: JSON.stringify(evt)
  });
}

Make sure the CRM side has sequences that watch for properties like last_visit_at and next_appointment_at to start rebooking or win-back flows.

6) Backfill safely via scheduled CSV email exports

Zenoti can schedule report emails. Pipe those to a service mailbox and parse attachments to backfill history without hammering APIs.

// backfill/email-csv.js
import { simpleParser } from "mailparser";
import imaps from "imap-simple";
import Papa from "papaparse";
 
export async function consumeCsvMailbox() {
  const conn = await imaps.connect({
    imap: {
      user: process.env.IMAP_USER,
      password: process.env.IMAP_PASS,
      host: process.env.IMAP_HOST,
      port: 993,
      tls: true
    }
  });
  await conn.openBox("INBOX");
  const searchCriteria = ["UNSEEN"];
  const fetchOptions = { bodies: [""], struct: true };
  const messages = await conn.search(searchCriteria, fetchOptions);
  for (const m of messages) {
    const parsed = await simpleParser(m.parts.find(p => p.which === "").body);
    for (const att of parsed.attachments || []) {
      if (!att.filename.endsWith(".csv")) continue;
      const { data } = Papa.parse(att.content.toString(), { header: true });
      // Map CSV rows to CRM upserts here
    }
  }
  await conn.end();
}

This path also insulates you from temporary webhook inactivation while you fix an endpoint.

Where it gets complicated

  • Zapier limits and field gaps: The official Zapier app supports up to 40 webhook subscriptions, does not support custom fields, and only works with cloud-hosted Zenoti accounts. If you need custom fields or many event subscriptions, use the API path.
  • Webhook failure handling: Zenoti documents that failing webhooks can be inactivated until issues are resolved. Build fast 2xx responses and a retryable job queue so a transient CRM outage does not disable your feed.
  • Community Make app: A Make.com connector exists but is community-developed and not maintained by Make. Treat it as a convenience for light tasks, not the backbone of a production sync.
  • Real-time versus correctness: Always re-fetch the guest on webhook before writing to your CRM so you do not sync a stale payload when multiple edits land quickly.
  • Appointment semantics: Rebooking logic depends on next_appointment_at and completion state. Model the window by service. For example, inject a different rebook cadence for injectables versus facials.
  • Multi-location mapping: Normalize location identifiers so segments and reports work across branches without duplicating contacts.

What this actually changes

For a multi-location med-spa, this removed the weekly export ritual and stopped recall campaigns from missing clients who rebooked after a CSV was pulled. Staff saw rebooking prompts fire on time, recall flows align to the last service, and lapsed-client win-backs get sequenced without manual slicing. The structural value is retention: it is widely cited that acquiring a new customer can cost five to twenty-five times more than retaining an existing one (Harvard Business Review: https://hbr.org/2014/10/the-value-of-keeping-the-right-customers). Turning rebooking and recall into always-on flows protects that margin.

Frequently asked questions

Does Zenoti have an API we can use for this?

Yes. Zenoti publishes API docs on api.zenoti.com and documents token generation and guest retrieval. We use the token endpoint to obtain an access token and then call endpoints like guests to keep the CRM accurate.

Can this run in real time or is it a nightly batch?

It runs near real time using Zenoti webhook events such as Guest Created or Updated and Appointment Completed. We add a scheduled CSV email backfill to heal gaps and to load history safely.

Do we have to use Zapier or Make?

No. We use Zenoti's API and webhooks directly for production. Zapier exists but is capped at 40 webhook subscriptions and does not support custom fields. A Make.com app exists as community-developed, which we treat as non-critical.

Will custom fields map into our CRM?

Through the API path, yes. Zapier does not support custom fields for Zenoti, so if your retention logic depends on custom fields we integrate via the API and store those fields in your CRM.

How fast is the initial setup?

The build is measured in weeks, not months. We establish webhook subscriptions, token auth, CRM mappings, and backfill. Exact timing depends on how many custom fields and segments you want to drive.

If you are on Zenoti and want rebooking, recall, and win-back handled without exports, we have this running in production. See our related post on Boulevard for the same outcome in a different stack: /blog/boulevard-api-crm-rebooking-reviews. For broader CRM buildouts, see our services at /services#crm-automation or book a 15-minute call.

Want us to build this for you?

15-minute discovery call. No pitch. We tell you what to automate first.

Book a Discovery Call

Related reading