How to Export Your Data Out of Zenoti
Getting a full spa's history — catalog, staff, guest book, appointments, invoices, gift cards — out of Zenoti and into a database you control. Including the parts that went wrong.
Before you can leave a platform, you have to get your data out of it. This is a field guide to doing that with Zenoti — written after actually doing it, including the parts that went wrong.
Everything here is drawn from a working export that pulled a full spa's history — catalog, staff, guest book, appointments, invoices, and gift cards — out of Zenoti and into Postgres. The bugs described are real bugs that shipped, not hypotheticals.
Start here: there is probably no API key
This is the fact that shapes everything else, so it goes first.
Zenoti has a v1 REST API. It's the same API surface Zenoti's own appointment book uses, and it's complete enough to export effectively everything. But on a standard account, you have no long-lived API key. Unless you've bought API access, the only credential you can get is the short-lived bearer token that your own browser is already using — which you have to lift out of a logged-in Zenoti tab.
That token is opaque — not a decodable JWT, so you can't inspect its expiry locally. The only way to know whether a token is still good is to make a call and see. It lives roughly an hour. I re-captured it about four times in a single working session.
The consequences are worth stating plainly, because they determine your whole plan:
- Your export can't be truly unattended. A nightly cron will 401 within the day unless a human re-captures a token. Nothing fixes this except buying real API access.
- Long historical imports must be resumable. Mine died at week 61 of a 92-week backfill when the token expired mid-run. That is not an edge case — at roughly a minute per week of history, any real backfill outlives its own credential.
- Every write must be idempotent for the same reason. You will re-run this repeatedly, and a re-run has to heal rather than duplicate.
Getting the token
The easy way, which I found embarrassingly late: on a logged-in Zenoti tab, read window.globalWebApiToken from the page. That is the bearer value — no Bearer prefix, so add one. No devtools archaeology required.
The hard way, and why it's hard — because you may need it if that global ever moves. Zenoti's web app doesn't authorize with the Authorization header at all; it uses X-AuthorizationToken. So a capture hook watching for Authorization sees nothing and you conclude, wrongly, that no call is happening. The REST base you'll be calling does take Authorization: Bearer … — the two headers coexist, which is exactly why this wastes an afternoon. If you do hook, hook both fetch and XMLHttpRequest.prototype.setRequestHeader, because the SPA uses both. And to trigger a v1 call, flip the calendar date forward and back — the guest search box often doesn't fire one.
Two more traps that cost real time:
- Don't URL-encode the token. A transport that runs
.replace(/\+/g, "%20")will turn every+in the token into a space. Mine contained twelve of them. The symptom is maddening: the same token works from the browser and 401s from your server with a byte-identical header. - Your session can expire before your token does. A capture from a stale tab hands you a token that was dead on arrival.
Keep it strictly read-only — trigger a request, don't click Save or Delete while you're in there. And check your contract: this is your own data and your own session, but automating against an API you weren't explicitly granted is worth a look at your terms before you build on it.
Store the token where you can change it without a redeploy. It's the value you'll replace most often, by a wide margin.
The endpoints
The base URL is regional — something like https://api<region>.cu.zenoti.com/v1, where the region matches your cluster (amrs11 is Americas). Your center ID is a GUID that goes in the path or query.
| Endpoint | Returns | Notes |
|---|---|---|
/centers/{id}/categories | Service categories | Paged |
/centers/{id}/services?category_id= | Services in one category | One call per category — the only way to learn a service's category |
/centers/{id}/services?expand=catalog_info | Full catalog | can_book only appears with expand |
/centers/{id}/employees | Staff | job_info.name is the title |
/centers/{id}/rooms | Rooms | Paged |
/centers/{id}/equipments | Equipment | Note the plural key: equipments |
/centers/{id}/packages | Packages | List omits price |
/centers/{id}/packages/{id} | One package | One call each, just to get price_info.sale_price |
/centers/{id}/schedules | Provider rosters | Capped at 30 days, and end_date is exclusive |
/guests?center_id= | Guest book | Paged |
/guests/{id}/gift_cards | Gift cards for one guest | One call per guest — no bulk listing exists |
/appointments?center_id=&start_date=&end_date= | Appointments | Bare array, not an envelope. Capped at 10 days. The time component is mandatory |
/invoices/{id} | Invoice detail | One call each. transactions may be permanently empty |
/sales/salesreport?...&item_type=7&status=2 | Sales report | Range capped at 7 days. No invoice GUID |
Pagination has a silent ceiling
Paged endpoints take page (1-based) and size. There's a page_info object in some responses — its casing is inconsistent across endpoints (page_Info with a capital I shows up too), which is a good reason to ignore it entirely and just count rows: if you got back fewer than you asked for, that was the last page.
The trap is the safety valve. If you cap pages at 200 with a page size of 100, you have silently capped that endpoint at 20,000 rows — and nothing will tell you. A guest book larger than your ceiling truncates without an error. Set the cap deliberately and log when you hit it.
Every date range has a different, undocumented cap
Three endpoints, three caps, none of them in the docs. I found all three by probing:
- Appointments: 10 days per call.
- Schedules: over 30 days is rejected — with an actual error,
{"error_message":"Cannot get for date difference more than 30 days","code":4047}. At least this one fails loudly. - Sales report: 7 days per call.
And two ways a range can lie to you rather than fail:
Omit the time component and appointments returns an empty array. start_date=2026-06-29 gives you [] and a cheerful 200. start_date=2026-06-29T00:00:00 gives you the appointments. Not an error, not a warning — just a well-formed empty day. If you're backfilling a year, that's 365 well-formed empty days and a conclusion that your spa had no business in 2025.
The schedules end_date is exclusive. So a range that reads as inclusive quietly drops its last day — and because a missing roster means "nobody works today", every synced range wrote its final day as all providers off. That shipped and went unnoticed for months, because a day where everyone is off looks like a Sunday, not a bug. Walk it in ≤29-day chunks, querying one day past the inclusive end you actually want.
Export in foreign-key order
The order isn't arbitrary — later passes reference earlier ones:
- Categories, then services (services reference their category)
- Employees, rooms, equipment, packages
- Guest book
- Gift cards — after guests, so freshly imported guests are included in the walk
- Appointments, day by day — each day upserts its guest, then the appointment, then builds invoices
- Sales report — after the appointment pass, because the report's totals are more complete and should win
- Derived mappings — provider→service, service→equipment, computed locally
That step-6 ordering is a real decision, not a detail: both passes rebuild invoice line items wholesale, so whichever runs last wins. Run sales last.
The five bugs that will bite you
These all shipped. Each one is cheap to avoid if you know about it and expensive to diagnose if you don't.
1. The same GUID comes back in different letter case
This is the big one. Zenoti returns the same guest GUID with different capitalization depending on which endpoint you ask — the guest book and the appointments endpoint disagree. Postgres unique keys are case-sensitive. So each case variant creates a separate guest row.
The symptom is duplicate guests: two of the same person, same name, same phone. In our data it produced about 69 duplicate guests — the guest book went from 2,026 rows down to 1,957 once fixed.
The fix is one line, and it's safe because GUIDs are case-insensitive identifiers by definition: lowercase every external ID on the way in, before it's ever used as a key. Do this from day one. Retrofitting it means reconciling duplicates you've already created.
2. The sales report has no invoice GUID
Every other entity has a GUID. The sales report doesn't — it identifies invoices only by invoice_no and receipt_no. So when your sales pass tries to match the invoices your appointment pass already created, it has nothing reliable to match on.
Worse, the formats don't agree. Appointment-built invoices store a bare sequence like 4081. The sales report prefixes it with the center code: CTR4081. Match naively and every invoice looks new. Ours duplicated about 2,000 invoices before we caught it.
The fix is to match against a candidate list — a synthetic sale key, the raw number, the receipt number, and the prefix-stripped variant — update in place on a hit, and only insert if none match.
3. price.final is zero until checkout
The appointments endpoint only populates final once an appointment has been checked out. Before that it's 0 — not null, not absent. Zero.
That distinction matters, because it means a null-coalescing fallback doesn't save you. Write final ?? salesPrice and you'll cheerfully take the 0, and every invoice will total $0. You want the truthiness check — use final when it's a positive charged amount, otherwise fall back to the list price.
4. An empty response is a failure, not an empty catalog
If your export deactivates local rows that the source no longer returns — a reasonable reconcile design — then a failed API call that returns an empty list looks identical to "you have no services."
Guard it: only reconcile when the fetched set is non-empty. A genuinely empty catalog is not a thing that happens to a running business; a failed call absolutely is.
The one honest exception is appointments, where an empty day is legitimate — it means everything that day was cancelled. Which brings up a related trap: the appointments endpoint never returns cancelled appointments at all. They don't come back with a cancelled status; they simply aren't there. Any "did this get cancelled?" logic has to be inferred from absence, not read from a field.
5. Timestamps lie about their timezone
Two separate problems, both silent.
Zenoti's *_utc fields are UTC wall-clock strings with no timezone suffix — 2026-06-29T21:15:00. Parse that with a naive date constructor and you get whatever the runtime's local zone is. Append the Z yourself.
Then: never use the server's local zone for the center's business day. A serverless host running in UTC will shift every imported appointment by your center's UTC offset — which looks like your entire calendar is subtly, uniformly wrong. Resolve the center's timezone explicitly.
While you're in there, Zenoti uses sentinel dates instead of nulls, and they aren't consistent: year 0001 means "unset", and gift cards use year 2200 to mean "never expires". A < 1900 check catches the first. The second will sail into your database as a real date and sit there looking plausible for the next 174 years.
Bonus: the fields that don't mean what they say
A few more that cost me time, none of which are in any documentation:
- Appointment status
10is a blockout, not a completed appointment. I mapped it as completed and got a calendar full of phantom completed work. The codes were reverse-engineered against 958 real appointments:0/1booked,4checked-in,10blockout. Roughly a tenth of that sample were blockouts. - A guest's phone is sometimes an object, sometimes a string. The full guest record gives you
{ phone_code, number }; appointment payloads give you a pre-formatteddisplay_numberwithnumber: null. Type it as a string and you'll serialize the whole object into your text column — which is how 72 guests ended up with{"country_id":0,"number":null,…}printed next to their names on the calendar. - Tips can't be attributed to a payment method. Zenoti's per-method amounts exclude tips, so the invoice tip has to ride on one payment. We put it on the largest.
- Commissions aren't there at all.
/employees/{id}/commissionsis a 404, service details carry no commission field, and thejob_infopay fields are 0 for every provider. Before you build a migration path for it, check the admin UI by hand — in our case there was genuinely no commission data to migrate, and Zenoti models it as revenue-range slabs anyway, which a flat per-provider rate can't represent.
What makes the export slow
Most of the catalog is a single paged call each. The expensive parts are the per-record walks:
- Gift cards: one call per guest. There is no center-level gift-card listing. With ~2,000 guests that's ~2,000 API calls for one entity. Make this opt-in, not part of your routine sync.
- Packages: one call per package, purely because the list endpoint omits price.
- Services: one call per category, because the services list doesn't carry the category.
- Appointments: up to 10 days per call. We ran it one day at a time, which is simpler and made each day's failure isolated — but it means a year of history is 365 calls where it could have been 37.
- Invoice details: one call per invoice, if you want the enriched version.
A useful escape hatch: a sales-only historical import (skip the appointment pass) is one call per week instead of one per day. If you're backfilling years of financials, that's the difference between a coffee break and an afternoon.
There is no rate limiting, and that cuts both ways
I found no evidence of throttling, and the export ran fully serial for most passes — one request at a time is its own throttle. The gift-card walk used a bounded pool of 8 workers without trouble.
But the flip side is that there's no retry or backoff either, and a 429 or a transient 500 will abort the pass. Two things follow:
- Wrap per-record writes in try/catch. One malformed row should never kill a months-long import. We learned this when a non-GUID
invoice_item_id— the report occasionally carries an empty or non-GUID item id where a UUID column expects one — broke a multi-row insert and killed the historical import mid-run. - Watch your function timeout. A serverless function with a 5-minute ceiling can't do a multi-year import in one request. Slice it into date ranges and make each slice resumable.
Both of these are the same lesson as the idempotency rule: assume the export will die partway through, and make a re-run heal it rather than corrupt it.
What you cannot get
Confirmed by probing the API, not by reading docs:
- A package's included services. The
benefitsandcatalog_infofields come back null even withexpand. - Guest-owned package purchases.
- Any bulk gift-card listing — the per-guest walk is the only path.
And a structural warning: fields can be present and permanently empty. On our account, /invoices/{id} recorded no payment transactions at all — invoices were closed, but the transactions array was always empty. My first conclusion was that the payment data simply didn't exist. It did — it was in /sales/salesreport, a completely different endpoint, and it eventually yielded every card, cash, and gift-card payment in our history.
That's the lesson worth generalizing, and I got it wrong twice. I also probed about fifteen endpoint shapes looking for provider working hours, got 404 on all of them, and concluded the data couldn't be exported. It could — /centers/{id}/schedules had it the whole time. "I probed and it 404s" is not proof of absence. It's proof you haven't found the endpoint. In an API this under-documented, absence of evidence is genuinely not evidence of absence.
Do not cancel your subscription until you're sure
One last thing, and it's the most expensive advice on this page.
While the old system is still live, it is your backup. I once ran a test whose cleanup step reactivated eight services that had been legitimately inactive before the test — and the database alone couldn't tell me which eight. The only reason it was recoverable is that Zenoti was still running and still authoritative: a fresh catalog sync restored the correct flags.
The day you cancel, that escape hatch closes. Every "wait, was that field always like this?" becomes unanswerable, permanently. Export everything, run both systems in parallel until you've closed a full billing cycle on your own, verify your financial history against theirs — and only then stop paying.
The subscription is the cheapest insurance you will ever buy against your own migration.
The rule that matters most
If you take one thing from this: every write is an upsert on the external ID, and rows you created locally are yours.
Here's what it costs to learn that the hard way. During the transition both systems were live: guests were paying on our own card terminal, but Zenoti didn't know about those payments, so Zenoti still reported those invoices as open. Our resync dutifully overwrote status and paid_at with what Zenoti said. A guest's card was charged, the money was at the processor, and the invoice said unpaid. Twenty-one succeeded payments were stranded on invoices not marked paid.
The same upsert had a quieter twin. It also zeroed tip_cents, because Zenoti doesn't know about tips charged on our terminal either. Seventeen invoices lost the tips guests had actually left — and tip_cents is the exact field the payroll report reads. Nobody noticed from the code. It surfaced when the owner asked why some people apparently hadn't tipped.
That's the real shape of this failure: a sync that fails is loud, and a sync that silently rewrites financial history is not. You find it weeks later, from a question about payroll.
The discriminator that makes the fix possible is simple, and worth designing in from your very first import: imported payments carry an external ID; locally-collected ones have a null external ID. That one nullable column is what lets you tell your money from theirs. Then:
- Guard the downgrade. Before an invoice upsert, if the incoming status is open but a succeeded local payment exists, keep it paid.
- Heal unconditionally. Run a pass at the end of every sync that restores any invoice holding a local payment — status, paid time, tips, totals. Not because you know of a hole, but because you don't. Any future code path will eventually regress this, and the heal pass catches the ones you haven't thought of.
- Monitor it. Keep a standing query for the invariant: a closed invoice with a succeeded payment can't be sitting there marked open.
Two guards and a monitor for one invariant looks like belt and braces. It isn't. This is the only bug on this page that takes money from people and doesn't tell you.
Everything else here costs you time. This one costs your guests theirs.