Skip to content
← All guides
8 prompts · 10 min read

Export Your Zenoti Data With Claude Code

The same export as the technical guide — without writing the code. Eight prompts, each one carrying a bug that cost me a day.

Claude CodeData MigrationNo-CodeZenoti

I wrote a long technical guide about getting your data out of Zenoti. Several people read it and concluded the job was out of reach. That's the wrong lesson. That page is a map of the terrain — every rock I tripped on, written down. It looks intimidating for the same reason a map of a mountain looks intimidating. It isn't the climb.

This page is the climb. It's the same export, done with Claude Code, as a sequence of prompts you can copy.

Here's the thing worth understanding before you start: the hard part of this job was never the code. The code is a few hundred lines of fetch-and-upsert, and Claude Code writes that in minutes. The hard part was knowing that Zenoti returns the same guest ID in different letter case from different endpoints, and that this quietly creates a duplicate of every guest. I lost a day to that. You won't, because it's in the prompt below.

That's the actual trick here. The prompts carry the scar tissue. You're not asking Claude Code to be clever; you're handing it a map of the rocks.

What you need

  • A Zenoti login (an owner or admin account you can sign into)
  • Claude Code
  • Somewhere to put the data — a Postgres database. A free Neon or Supabase instance is fine
  • About an hour of attention, spread across a few hours of wall-clock

You do not need to know how to write the code. You do need to be able to read the output and notice when a number looks wrong — that part doesn't delegate.

A word on expectations

Be honest with yourself about which job you're doing.

Getting a copy of your data — every guest, appointment, invoice, and gift card in a database you own — is a couple of evenings. That's what this page gets you, and it's genuinely most of the value. It ends the "what happens if they lock me out" problem forever.

Running your business on it is a different project, an order of magnitude larger. That's the other guide.

Do the first one now, regardless of whether you ever do the second. An export is leverage even if you stay.

Step 0 — Get your token

The one step Claude Code can't do for you, because it requires being logged into your own browser.

There's no API key. Zenoti authenticates your export with the short-lived token your browser is already using, and you have to go get it.

  1. Log into Zenoti in Chrome.
  2. Open devtools (F12 or Cmd+Option+I) and click the Console tab.
  3. Type window.globalWebApiToken and press Enter.
  4. Copy the string it prints, without the quotes.

That's your token. Two things to know: it's good for roughly an hour, and it is not a password — it's a temporary session key. You'll be repeating this step a few times, so keep the tab open.

If that global doesn't exist (Zenoti may rename it), ask Claude Code:

If the token trick fails
I'm logged into Zenoti in Chrome and need to grab the bearer token my
browser is using for its v1 API calls, so I can use it for a read-only
export of my own data.

window.globalWebApiToken doesn't exist on this page. Give me a snippet to
paste into the devtools console that captures the token from an outgoing
request instead.

Two things that tripped me up before:
- Zenoti's web app sends the token as an X-AuthorizationToken header, NOT
as Authorization. A hook that only watches for Authorization sees nothing
and makes it look like no API call is happening.
- The SPA uses both fetch and XMLHttpRequest, so hook both.
- To trigger a v1 call, flipping the calendar date forward and back works
reliably. The guest search box often doesn't fire one.

Keep this read-only. You're triggering requests to observe them, not changing anything. Don't click Save or Delete while you're in there. And it's worth a look at your Zenoti terms — this is your own data and your own session, but that's your call to make knowingly.

Step 1 — The foundation

Start a new project folder, run claude, and paste this. Fill in the two bracketed values: your cluster hostname (visible in the devtools Network tab, something like apiamrs11) and your center ID.

Step 1 — client + connection test
I'm exporting my spa's data out of Zenoti into Postgres so I own a copy of
it. Set up the foundation. Don't write any export logic yet.

The API is Zenoti's v1 REST API at https://[YOUR-CLUSTER].cu.zenoti.com/v1
My center ID is [YOUR-CENTER-ID].

Auth is a short-lived bearer token I paste in — there is no API key. It
lives about an hour, so assume I'll be replacing it constantly.

Build:
1. A typed fetch client that reads the token from an env var and sends it
 as an "Authorization: Bearer ..." header. Never put it in a query
 string — it ends up in logs.
2. cache: "no-store" on every request.
3. A distinct AuthError type for 401/403, so "my token expired" is
 obviously different from "something is actually broken". This matters
 more than it sounds: I'll hit it constantly.
4. A paged fetch helper. Zenoti uses page (1-based) and size. Stop when a
 batch comes back smaller than the page size — don't trust the page_info
 object, its casing is inconsistent across endpoints.
5. If a paged fetch ever hits its max-page cap, log loudly. I'd rather see
 an error than silently export 20,000 of my 25,000 guests.

Then write a one-off script that calls /centers/{centerId}/categories and
prints what it gets, so I can confirm the token works before we build
anything else.

Run the test script. If you see your service categories, you're connected. If you get a 401, your token expired — grab a fresh one, that's normal.

Step 2 — The schema

This prompt contains the single most important instruction on this page.

Step 2 — database schema
Now the Postgres schema. Tables for: guests, services, service categories,
employees, rooms, equipment, packages, appointments, invoices, invoice line
items, payments, and gift cards.

Three rules that matter more than the column types:

1. Every table gets a nullable "zenoti_id" column with a unique index.
 Every write is an upsert on that id, so a re-run heals instead of
 duplicating. This export WILL die partway through — my token expires
 every hour — so idempotency isn't optional.

2. ALWAYS lowercase the Zenoti id before it's used as a key. Zenoti returns
 the same guest GUID in different letter case from different endpoints,
 and Postgres unique keys are case-sensitive, so each case variant creates
 a duplicate guest. This produced ~69 duplicate guests for me before I
 caught it. GUIDs are case-insensitive by definition, so lowercasing is
 always safe. Put it in one normalizeId() function and route every id
 through it.

3. zenoti_id IS NULL means "this row is mine, not imported". Nothing from
 the import may ever modify a row with a null zenoti_id. This will matter
 enormously later if I start taking payments in my own system while
 Zenoti is still running.

Also: Zenoti uses sentinel dates instead of nulls, and they're not
consistent. Year 0001 means "unset" and gift cards use year 2200 to mean
"never expires". Handle both at the mapping layer, not in the schema.

Timestamps come back as UTC wall-clock strings with NO timezone suffix
(like 2026-06-29T21:15:00). Append the Z when parsing or every appointment
lands at the wrong time.

Step 3 — The catalog

The easy pass. Small data, no traps beyond one FK ordering rule.

Step 3 — catalog
Write the catalog export. Run the passes in this order — it's forced by
foreign keys:

categories -> services -> employees -> rooms -> equipment -> packages

Endpoint notes that cost me time:
- /centers/{id}/categories gives categories. The services list does NOT
carry a service's category, so get services per category by calling
/centers/{id}/services?category_id=... once per category.
- Equipment is /centers/{id}/equipments — plural, not /equipment.
- The packages list omits price. You need /centers/{id}/packages/{id} per
package to get price_info.sale_price.
- Employee emails often don't exist at all. If your schema needs one,
synthesize a stable placeholder rather than failing the import.

Print a count per entity when it finishes.

Step 4 — The guest book

Step 4 — guests
Now the guest book, from /guests?center_id=...

- Bulk upsert in chunks of ~500 rather than one statement per guest.
- Lowercase every id (the normalizeId rule from the schema step).
- A guest's phone is sometimes an object and sometimes a string. Full guest
records give you { phone_code, number }; appointment payloads give you a
pre-formatted display_number with number: null. If you type it as a
string you'll serialize the whole object into the column — I had 72
guests with raw JSON printed next to their names. Normalize to E.164.

Then tell me how many guests you imported. I'll sanity-check it against
what Zenoti's UI says.

That last line matters. Check the count. This is the step where you, not Claude Code, are the safeguard — you're the one who knows roughly how many clients you have.

Step 5 — Appointments

Two silent traps here. Both are in the prompt.

Step 5 — appointments
Now appointments, from /appointments?center_id=&start_date=&end_date=

Critical, both of which fail SILENTLY:

1. The time component is MANDATORY. start_date=2026-06-29 returns an empty
 array with a 200 status. start_date=2026-06-29T00:00:00 returns the real
 data. Omit it and you'll conclude my spa had no business last year.

2. This endpoint returns a BARE JSON array, not an object with a wrapper
 key like the others.

Also:
- It caps at 10 days per call, so chunk the range.
- Cancelled appointments are simply NOT RETURNED. There's no cancelled
status and no tombstone. Absence is the only signal you get.
- Status codes: 0 and 1 are booked, 4 is checked-in, 10 is a BLOCKOUT (not
a completed appointment — I mapped that wrong and got a calendar full of
phantom completed work). Default anything unknown to booked.

Let me pick the date range. Start with just last month so we can check it
before committing to a full backfill.

Run it for one month first. Open your database, pick a day, and compare it against Zenoti's calendar for that same day. If they match, you can trust the whole backfill.

Step 6 — The money

The pass where getting it wrong is worst, and the one place I'd slow down.

Step 6 — invoices and sales
Now the financial history, from
/sales/salesreport?center_id=&start_date=&end_date=&item_type=7&status=2

Traps:

1. The endpoint caps at 7 days per call. Chunk weekly.

2. The sales report has NO invoice GUID — only invoice_no and receipt_no.
 Every other entity has a GUID; this one doesn't. And the numbering
 disagrees between sources: appointment-built invoices carry a bare
 number like "4081" while the sales report prefixes the center code, like
 "CTR4081". Match naively and every invoice looks new — this duplicated
 about 2,000 invoices for me. Match on a candidate list: a synthetic sale
 key, the raw number, the receipt number, and the prefix-stripped
 variant. Only insert if none match.

3. Invoice numbers are REUSED across years, so the number alone is not
 unique.

4. Run this pass AFTER appointments. Both passes build invoice line items,
 and the sales report's totals are more complete, so it should win.

5. price.final on an appointment is 0 (not null!) until checkout. So
 "final ?? listPrice" takes the zero and every invoice totals $0. Use a
 positive-value check, not null-coalescing.

6. Wrap each invoice's write in try/catch and count failures instead of
 aborting. The report sometimes carries a non-GUID invoice_item_id where
 a uuid column expects one; one bad row killed a months-long import for
 me. One bad invoice must not kill the run.

Report imported / skipped counts at the end.

Step 7 — Gift cards

Slow, and worth understanding why before you run it.

Step 7 — gift cards
Now gift cards. There is no center-level gift card listing — I checked, the
endpoints 404. The only path is /guests/{guestId}/gift_cards, one call per
guest, so this is slow by construction.

- Run it after guests, so newly imported guests are included.
- Use a bounded concurrency pool of about 8. I ran ~2,000 guests that way
with no rate limiting and it took about a minute.
- Count failures separately. One of my guests has a broken record whose
gift cards never fetch — don't let that abort the pass.
- Gift cards use year 2200 to mean "never expires".

Step 8 — The backfill, and checking it

Now widen the date range and let it run. Expect the token to expire mid-way — mine died at week 61 of a 92-week backfill. That's why every write is an upsert: you paste a fresh token and re-run, and it picks up rather than duplicating.

Step 8 — verify
The import is done. Now help me verify it, because I don't trust it yet.

Write me a summary query showing:
- Total guests, appointments, invoices, payments, gift cards
- Total paid revenue, and revenue by month
- Earliest and latest appointment date
- Any invoice with a $0 total (this would mean the price.final bug)
- Any duplicate guests: same name or phone with different zenoti_id (this
would mean the letter-case bug)
- Any appointment referencing a guest or service that doesn't exist

Then give me a short checklist of things to compare by hand against
Zenoti's own reports.

Then do the boring part. Pull up Zenoti's own revenue report for a few months and compare it to yours. If they disagree, the export is wrong — and the whole point of doing this while you're still paying for Zenoti is that you can still tell.

Do not cancel your subscription yet

The most expensive advice on this page, and it isn't technical.

While Zenoti is live, it's your backup. Every "wait — was that always like that?" has an answer you can go look up. The day you cancel, those questions become permanently unanswerable.

Export everything. Run in parallel. Reconcile a full month against their reports. Then stop paying. The subscription is the cheapest insurance you'll ever buy against your own migration.

What this actually costs

An evening for steps 0 through 4. Another for the money and the backfill. A third for verification, which is the one that decides whether you trust it.

And afterwards you own your data — which changes the conversation with your vendor, whether or not you ever leave.

The technical detail behind every trap above is in the long version, if you want to know why rather than just what. If you're still deciding whether to leave at all, here's why we did — the pricing, mostly. And if you decide to go further and replace the platform outright, that guide is here.